diagnostics: System → Diagnostics with a network test probe; RTP_PORT_START/END

The server checks itself (public IP, DNS, local networks, phone ACL, SIP transports, free audio
ports) and says how to fix each problem. A quick or complete network test asks the probe
(apps/probe, own image) to test this server from outside: HTTP/HTTPS, SIP over UDP/TCP/TLS, the
browser phone's /ws, sampled audio ports. The probe only tests the address that asks.
The audio port range comes from RTP_PORT_START/END (default 10000-20000).
This commit is contained in:
iamdoubz committed 2026-10-21 12:37:16 -05:00
1 parent 90b3081368
commit 65a0438dde
50 files changed
+1704 -59

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ declare module 'fastify' {
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator;
/** Decorated by the caller too (tests pass a fake). */
ami: Pick<AmiClient, 'action' | 'isConnected'>;
ami: Pick<AmiClient, 'action' | 'command' | 'isConnected'>;
}
}
+10
View File
@@ -19,6 +19,16 @@ describe('config', () => {
expect(() => loadConfig({ ...env, APP_SECRET: undefined })).toThrow(ConfigError);
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
});
it('call audio ports: 10000–20000 by default; the end above the start', () => {
expect(loadConfig(env)).toMatchObject({ RTP_PORT_START: 10000, RTP_PORT_END: 20000 });
expect(loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '40199' })).toMatchObject({
RTP_PORT_START: 40000,
RTP_PORT_END: 40199,
});
expect(() => loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '30000' })).toThrow(
/RTP_PORT_END/,
);
});
it('rejects a non-postgres DATABASE_URL', () => {
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
});
+45 -37
View File
@@ -1,43 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
const EnvSchema = z.object({
APP_SECRET: z
.string()
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
PUBLIC_HOSTNAME: z.string().min(1),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
INITIAL_ADMIN_EMAIL: z.email(),
INITIAL_ADMIN_PASSWORD: z.string().min(1),
AMI_HOST: z.string().default('127.0.0.1'),
AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
AMI_USERNAME: z.string().default('selfphoneng'),
AMI_SECRET: z.string().min(8),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
/** Uploaded prompts, played as custom/<id> (04 §6). Dev: ../../data/sounds-custom */
SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
/** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
/** Backups (04 §8.1). Dev: ../../data/backups */
BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'),
/** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
/** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
/** MixMonitor's directory; recordings are YYYY/MM/DD/<uniqueid>.wav under it (03 sp-sub-record). */
RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'),
/** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
WEBRTC_WS_URL: z.url().optional(),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
/** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */
SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false),
TZ: z.string().default('America/Chicago'),
});
const EnvSchema = z
.object({
APP_SECRET: z
.string()
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
PUBLIC_HOSTNAME: z.string().min(1),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
INITIAL_ADMIN_EMAIL: z.email(),
INITIAL_ADMIN_PASSWORD: z.string().min(1),
AMI_HOST: z.string().default('127.0.0.1'),
AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
AMI_USERNAME: z.string().default('selfphoneng'),
AMI_SECRET: z.string().min(8),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
/** Uploaded prompts, played as custom/<id> (04 §6). Dev: ../../data/sounds-custom */
SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
/** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
/** Backups (04 §8.1). Dev: ../../data/backups */
BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'),
/** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
/** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
/** MixMonitor's directory; recordings are YYYY/MM/DD/<uniqueid>.wav under it (03 sp-sub-record). */
RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'),
/** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
WEBRTC_WS_URL: z.url().optional(),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
/** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */
SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false),
TZ: z.string().default('America/Chicago'),
})
.refine((c) => c.RTP_PORT_END > c.RTP_PORT_START, {
path: ['RTP_PORT_END'],
message: 'must be above RTP_PORT_START',
});
export type Config = z.infer<typeof EnvSchema>;
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,4 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
stunaddr = stun.example.org:3478
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
+3 -1
View File
@@ -101,6 +101,8 @@ export type GeneratorDeps = {
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
onApplied?: (r: ApplyResult) => void;
debounceMs?: number;
/** RTP_PORT_START/END (config.ts). */
rtpPorts?: { start: number; end: number };
};
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -165,7 +167,7 @@ export function createGenerator(deps: GeneratorDeps) {
restartRequired: false,
};
try {
const snap = await loadSnapshot(db);
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map<GeneratedFileName, string | null>();
+4
View File
@@ -203,8 +203,12 @@ export type Snapshot = {
mohClasses: string[];
/** Played before recording a call that a phone number records (Settings → Recording). */
recordingAnnouncement: string | null;
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
rtpPorts?: { start: number; end: number };
};
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
export function todayIn(timezone: string, now = new Date()): string {
// en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
+14 -2
View File
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
import { CLASS_RANK } from '@selfphoneng/shared';
import { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts';
import {
byNumber,
DEFAULT_RTP_PORTS,
type SnapExtension,
type SnapTrunk,
type Snapshot,
} from './load.ts';
import * as s from './sanitize.ts';
const section = (title: string, lines: (string | false | null | undefined)[]) =>
@@ -275,7 +281,13 @@ export function renderPjsip(snap: Snapshot): string {
export function renderRtp(snap: Snapshot): string {
const st = snap.settings;
const lines = ['icesupport = yes'];
const rtp = snap.rtpPorts ?? DEFAULT_RTP_PORTS;
const lines = [
// After rtp.conf's own 10000–20000 (Asterisk takes a setting's last value): RTP_PORT_* (env).
`rtpstart = ${s.digits(String(rtp.start))}`,
`rtpend = ${s.digits(String(rtp.end))}`,
'icesupport = yes',
];
if (st['network.stunServer']) lines.push(`stunaddr = ${hostPort(st['network.stunServer'])}`);
if (st['network.lanIp'] && st['network.publicIp'])
lines.push(
+1
View File
@@ -46,6 +46,7 @@ const generator = createGenerator({
db,
ami,
dir: resolve(config.ASTERISK_CONFIG_DIR),
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
log: app.log,
onApplied: (r) => {
live.emit('message', {
+24 -3
View File
@@ -5,7 +5,13 @@ import { networkInterfaces } from 'node:os';
import { join, resolve } from 'node:path';
import { desc, lt } from 'drizzle-orm';
import { z } from 'zod';
import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared';
import type {
DiagnosticsDto,
ListResponse,
NetworkTestDto,
SystemEventDto,
SystemStatus,
} from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
@@ -14,7 +20,8 @@ import { systemEvents } from '../db/schema.ts';
import { appVersion } from './about.ts';
import { csvCell } from './calls.ts';
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts';
import { HttpError, notFound } from '../errors.ts';
import { badRequest, HttpError, notFound } from '../errors.ts';
import { insideChecks, networkTest } from '../services/diagnostics.ts';
import { AmiDisconnectedError } from '../ami/client.ts';
import { GENERATED_FILES } from '../generator/index.ts';
@@ -67,7 +74,7 @@ export async function systemRoutes(app: FastifyInstance) {
sip: s['sip.udpPort'],
sipTcp: s['sip.tcpEnabled'],
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null,
rtp: '10000–20000', // asterisk/conf/rtp.conf
rtp: `${app.config.RTP_PORT_START}–${app.config.RTP_PORT_END}`, // RTP_PORT_START/END
https: 443,
},
ami: { connected: app.ami.isConnected() },
@@ -163,6 +170,20 @@ export async function systemRoutes(app: FastifyInstance) {
return reply.sendFile(name, dir);
});
// Diagnostics (08 §2.6): the inside checks; the probe's outside test, quick or complete.
const diag = () => ({ db: app.db, config: app.config, ami: app.ami });
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
checks: await insideChecks(diag()),
}));
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
const { mode } = z
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
.parse(req.body ?? {});
if (!(await getAllSettings(app.db))['network.probeUrl'])
throw badRequest('No network test probe set (Settings → Network)');
return networkTest(diag(), mode);
});
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
GENERATED_FILES.map(async (name) => ({
+483
View File
@@ -0,0 +1,483 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System → Diagnostics (08 §2.6). insideChecks: what this server can tell about itself (settings,
// DNS, listening transports, the audio port range). networkTest: the probe (apps/probe) tests it
// from outside, the only way to know what a phone on mobile data or the SIP provider can reach.
import { randomBytes } from 'node:crypto';
import { createSocket, type Socket } from 'node:dgram';
import { resolve4 } from 'node:dns/promises';
import { BlockList, isIPv4 } from 'node:net';
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
import type { AmiClient } from '../ami/client.ts';
import type { Config } from '../config.ts';
import type { Db } from '../db/client.ts';
import { detectPublicIp } from '../jobs.ts';
import { getAllSettings } from './settings.ts';
export type DiagDeps = {
db: Db;
config: Pick<Config, 'PUBLIC_HOSTNAME' | 'RTP_PORT_START' | 'RTP_PORT_END'>;
ami: Pick<AmiClient, 'isConnected' | 'command'>;
/** Overridable in tests. */
lookupPublicIp?: () => Promise<string | null>;
resolveHost?: (host: string) => Promise<string[]>;
fetchProbe?: typeof fetch;
};
const PRIVATE = new BlockList();
for (const [a, b] of [
['10.0.0.0', 8],
['172.16.0.0', 12],
['192.168.0.0', 16],
['100.64.0.0', 10],
['127.0.0.0', 8],
] as const)
PRIVATE.addSubnet(a, b, 'ipv4');
const inNets = (ip: string, nets: string[]) => {
const list = new BlockList();
for (const n of nets) {
const [a, b] = n.split('/');
list.addSubnet(a!, Number(b), 'ipv4');
}
return list.check(ip, 'ipv4');
};
const check = (
id: string,
label: string,
status: DiagnosticCheck['status'],
detail: string,
fix?: string,
): DiagnosticCheck => ({ id, label, status, detail, ...(fix && { fix }) });
/** Even ports in the range that something else already holds (RTP takes even ones). */
async function busyRtpPorts(start: number, end: number): Promise<number> {
const ports: number[] = [];
for (let p = start + (start % 2); p <= end; p += 2) ports.push(p);
let busy = 0;
// ponytail: binds every even port once, 200 at a time (~1 s for 10 000 ports).
for (let i = 0; i < ports.length; i += 200)
await Promise.all(
ports.slice(i, i + 200).map(
(port) =>
new Promise<void>((done) => {
const s = createSocket('udp4');
s.once('error', () => (busy++, s.close(), done()));
s.bind(port, () => s.close(done));
}),
),
);
return busy;
}
export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
const s = await getAllSettings(d.db);
const host = d.config.PUBLIC_HOSTNAME;
const out: DiagnosticCheck[] = [];
const setIp = s['network.publicIp'];
const seenIp = await (d.lookupPublicIp ?? detectPublicIp)();
const ip = setIp ?? seenIp;
if (!setIp)
out.push(
check(
'public-ip',
'Public IP',
'fail',
"Not set. Phones outside your network are given the server's private address, so their calls fail or have no sound.",
`Settings → Network → Public IP: ${seenIp ?? "this server's internet address"}, then restart telephony (System).`,
),
);
else if (seenIp && seenIp !== setIp)
out.push(
check(
'public-ip',
'Public IP',
'fail',
`Set to ${setIp}, but this server reaches the internet from ${seenIp}.`,
`Settings → Network → Public IP: ${seenIp}, then restart telephony. If it keeps changing, turn on "Re-check the public IP".`,
),
);
else
out.push(
check(
'public-ip',
'Public IP',
seenIp ? 'ok' : 'warn',
seenIp ? setIp : `${setIp} (couldn't look it up to compare: no internet?)`,
),
);
const names = await (d.resolveHost ?? resolve4)(host).catch(() => [] as string[]);
if (!names.length)
out.push(
check(
'dns',
`DNS for ${host}`,
'fail',
`${host} doesn't resolve. Phones and the browser find the server by this name.`,
`At your DNS provider, add an A record: ${host} → ${ip ?? 'your public IP'}.`,
),
);
else if (ip && !names.includes(ip))
out.push(
check(
'dns',
`DNS for ${host}`,
'fail',
`${host} points at ${names.join(', ')}, not ${ip}.`,
`Point the A record at ${ip}. With Cloudflare, set it to "DNS only" (grey cloud): its proxy carries web pages, not phone calls (SIP).`,
),
);
else out.push(check('dns', `DNS for ${host}`, 'ok', `${host} → ${names.join(', ')}`));
const nets = s['network.localNets'];
const lan = s['network.lanIp'];
if (lan && !inNets(lan, nets))
out.push(
check(
'local-nets',
'Local networks',
'fail',
`The server's LAN address ${lan} isn't in them (${nets.join(', ') || 'none'}).`,
`Settings → Network: add ${lan.replace(/\.\d+$/, '.0')}/24 (or your LAN's range) to the local networks.`,
),
);
else out.push(check('local-nets', 'Local networks', 'ok', nets.join(', ') || 'none'));
out.push(
s['sip.restrictPhonesToLocalNets']
? check(
'phone-acl',
'Where phones can sign in from',
'warn',
`Only from ${[...nets, ...s['sip.extraAllowedNets']].join(', ')}. Phone apps on mobile data, or phones at home, are refused.`,
'Settings → Security: untick "Only accept desk phones from the networks below", or add the networks they call from.',
)
: check('phone-acl', 'Where phones can sign in from', 'ok', 'Anywhere, with their password'),
);
if (s['provisioning.lanOnly'])
out.push(
check(
'prov-lan',
'Phone setup files',
'warn',
'Only phones on the local networks get them: the Linphone QR code fails on mobile data.',
'Settings → Security: untick "Only to phones on the local networks" while setting up phones elsewhere.',
),
);
const port = s['sip.udpPort'];
if (!d.ami.isConnected())
out.push(
check(
'sip',
'SIP',
'fail',
"Telephony (Asterisk) isn't reachable.",
'Check that the asterisk container runs: docker compose ps, then docker compose logs asterisk.',
),
);
else {
const t = await d.ami.command('pjsip show transports').catch(() => '');
const want = [
['transport-udp', `UDP ${port}`],
...(s['sip.tcpEnabled'] ? [['transport-tcp', `TCP ${port}`]] : []),
...(s['sip.tlsEnabled'] ? [['transport-tls', `TLS ${s['sip.tlsPort']}`]] : []),
];
const missing = want.filter(([name]) => !t.includes(name!)).map(([, what]) => what);
out.push(
missing.length
? check(
'sip',
'SIP',
'fail',
`Not listening on ${missing.join(', ')}.`,
'Restart telephony (System): ports and TLS changes only take effect then.',
)
: check('sip', 'SIP', 'ok', `Listening on ${want.map(([, w]) => w).join(', ')}`),
);
}
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
const total = Math.floor((b - a) / 2) + 1;
const busy = await busyRtpPorts(a, b);
const range = `UDP ${a}–${b}`;
out.push(
busy > total / 4
? check(
'rtp',
'Call audio ports',
'warn',
`${range}: ${busy} of ${total} are taken by other programs (or calls).`,
`Pick a free range with RTP_PORT_START and RTP_PORT_END (in .env, or the api's environment in your stack), restart, and open that range instead. Two ports per call: 200 ports is plenty for a small office.`,
)
: check(
'rtp',
'Call audio ports',
'ok',
`${range}, ${total} usable${busy ? `, ${busy} in use` : ''}`,
),
);
out.push(
s['network.probeUrl']
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
: check(
'probe',
'Network test probe',
'skip',
'None set, so nothing is tested from outside your network.',
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
),
);
return out;
}
type ProbeResult = { id: string; ok: boolean; detail: string };
/** The probe's reason in brackets, unless it only repeats "no answer". */
const why = (detail: string) => (detail === 'no answer' ? '' : ` (${detail})`);
/** The probe's outside tests; `complete` adds TLS, the browser phone's /ws and audio ports. */
export async function networkTest(
d: DiagDeps,
mode: 'quick' | 'complete',
): Promise<NetworkTestDto> {
const s = await getAllSettings(d.db);
const probe = s['network.probeUrl'];
if (!probe) throw new Error('No network test probe set (Settings → Network)');
const host = d.config.PUBLIC_HOSTNAME;
const port = s['sip.udpPort'];
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
const lan = s['network.lanIp'] ?? 'this server';
const tests: Record<string, unknown>[] = [
{ id: 'http', kind: 'http', port: 80, path: '/' },
{ id: 'https', kind: 'https', port: 443, path: '/healthz' },
{ id: 'sip-udp', kind: 'sip-udp', port },
...(s['sip.tcpEnabled'] ? [{ id: 'sip-tcp', kind: 'sip-tcp', port }] : []),
];
// Audio ports: a few, listened on here for the probe's datagrams.
const listeners: { port: number; sock: Socket; got: boolean }[] = [];
if (mode === 'complete') {
tests.push({ id: 'ws', kind: 'https', port: 443, path: '/ws', websocket: true });
if (s['sip.tlsEnabled']) tests.push({ id: 'sip-tls', kind: 'sip-tls', port: s['sip.tlsPort'] });
const even = (p: number) => p - (p % 2);
const sample = [
...new Set([
even(a + 1),
even(b),
even(a + Math.floor((b - a) / 2)),
even(Math.min(b, a + 2 + Math.floor(Math.random() * Math.max(0, b - a - 2)))),
]),
];
const token = randomBytes(8).toString('hex');
for (const p of sample) {
const sock = createSocket('udp4');
const ok = await new Promise<boolean>((r) => {
sock.once('error', () => r(false));
sock.bind(p, () => r(true));
});
if (!ok) continue; // a call or another program has it: try the others
const l = { port: p, sock, got: false };
sock.on('message', (m) => (l.got ||= m.toString().includes(token)));
listeners.push(l);
tests.push({ id: `rtp-${p}`, kind: 'udp', port: p, token });
}
}
let ip: string | null = null;
let results: ProbeResult[] = [];
let probeError: string | null = null;
try {
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ host, tests }),
signal: AbortSignal.timeout(30_000),
});
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
// Datagrams sent last arrive just after the answer.
await new Promise((r) => setTimeout(r, 700));
} catch (e) {
probeError = (e as Error).message;
} finally {
for (const l of listeners) l.sock.close();
}
if (probeError)
return {
mode,
testedIp: null,
checks: [
check(
'probe',
'Network test probe',
'fail',
`${probe} didn't answer: ${probeError}`,
'Check the probe runs and is reachable from this server, then try again.',
),
],
};
const res = (id: string) => results.find((x) => x.id === id);
const out: DiagnosticCheck[] = [];
if (ip && isIPv4(ip) && PRIVATE.check(ip, 'ipv4'))
out.push(
check(
'probe-ip',
'Tested address',
'warn',
`The probe saw ${ip}, a private address: it's inside your network, so these results don't show what the internet sees.`,
'Run the probe on a machine outside this network.',
),
);
else if (ip && s['network.publicIp'] && ip !== s['network.publicIp'])
out.push(
check(
'probe-ip',
'Tested address',
'warn',
`The probe saw this server as ${ip}, but the public IP is set to ${s['network.publicIp']} (a VPN, or a changed address?).`,
`If ${ip} is right: Settings → Network → Public IP, then restart telephony.`,
),
);
else if (ip) out.push(check('probe-ip', 'Tested address', 'ok', ip));
const http = res('http');
if (http)
out.push(
http.ok
? check('http', 'HTTP (TCP 80)', 'ok', http.detail)
: check(
'http',
'HTTP (TCP 80)',
'warn',
`Not reachable${why(http.detail)}. Only needed to get the HTTPS certificate over HTTP (the bundled Caddy, or certbot).`,
'If you need it: forward TCP 80 to this server on the router, and sudo ufw allow 80/tcp.',
),
);
const https = res('https');
if (https) {
const status = Number(/HTTP (\d{3})/.exec(https.detail)?.[1]);
out.push(
!https.ok
? check(
'https',
'HTTPS (TCP 443)',
'fail',
`Not reachable${why(https.detail)}. The web app, the browser phone and phone setup all use it.`,
`Forward TCP 443 to ${lan} on the router, and sudo ufw allow 443/tcp.`,
)
: status !== 200
? check(
'https',
'HTTPS (TCP 443)',
'fail',
`Reachable, but answered ${https.detail.split('; ')[0]} instead of SelfPhoneNG.`,
"Your reverse proxy can't reach the api: it must send this host name to 127.0.0.1 on the api's port (API_PORT, 8080 by default).",
)
: /certificate isn't valid/.test(https.detail)
? check(
'https',
'HTTPS (TCP 443)',
'warn',
https.detail,
`Get a certificate for ${host}: the bundled Caddy does it by itself; with your own proxy, certbot.`,
)
: check('https', 'HTTPS (TCP 443)', 'ok', https.detail),
);
}
const udp = res('sip-udp');
if (udp)
out.push(
udp.ok
? check('sip-udp', `SIP (UDP ${port})`, 'ok', `Asterisk answered: ${udp.detail}`)
: check(
'sip-udp',
`SIP (UDP ${port})`,
'fail',
`No answer${why(udp.detail)}. Phones away from the office, phone apps on mobile data and the SIP provider's calls all need it.`,
`Forward UDP ${port} to ${lan} on the router (on both, behind two), sudo ufw allow ${port}/udp, and turn off SIP ALG on the router.`,
),
);
const tcp = res('sip-tcp');
if (tcp)
out.push(
tcp.ok
? check('sip-tcp', `SIP (TCP ${port})`, 'ok', `Asterisk answered: ${tcp.detail}`)
: check(
'sip-tcp',
`SIP (TCP ${port})`,
'warn',
`No answer${why(tcp.detail)}. Only phones set to TCP need it.`,
`Forward TCP ${port} to ${lan} and sudo ufw allow ${port}/tcp, or keep phones on UDP.`,
),
);
const tls = res('sip-tls');
if (tls)
out.push(
tls.ok
? check(
'sip-tls',
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
'ok',
`Asterisk answered: ${tls.detail}`,
)
: check(
'sip-tls',
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
'fail',
`No answer${why(tls.detail)}.`,
`Forward TCP ${s['sip.tlsPort']} to ${lan} and sudo ufw allow ${s['sip.tlsPort']}/tcp.`,
),
);
const ws = res('ws');
if (ws)
out.push(
ws.ok
? check('ws', 'Browser phone (/ws)', 'ok', ws.detail)
: check(
'ws',
'Browser phone (/ws)',
'fail',
`The WebSocket didn't open${why(ws.detail)}.`,
'Your reverse proxy must pass WebSocket upgrades on /ws to 127.0.0.1:8088 (see deploy/nginx/selfphoneng.conf.example).',
),
);
if (mode === 'complete') {
const got = listeners.filter((l) => l.got);
const range = `UDP ${a}–${b}`;
const fix = `Forward ${range} to ${lan} on the router, and sudo ufw allow ${a}:${b}/udp. Without it calls connect but have no sound, or only one way.`;
out.push(
!listeners.length
? check(
'rtp',
`Call audio (${range})`,
'skip',
'Every sampled port was in use here: try again.',
)
: got.length === listeners.length
? check(
'rtp',
`Call audio (${range})`,
'ok',
`${got.length} of ${listeners.length} sampled ports got through`,
)
: check(
'rtp',
`Call audio (${range})`,
got.length ? 'warn' : 'fail',
`${got.length} of ${listeners.length} sampled ports got through (missing: ${listeners
.filter((l) => !l.got)
.map((l) => l.port)
.join(', ')}).`,
got.length
? `The forward or firewall rule doesn't cover the whole range. ${fix}`
: fix,
),
);
}
return { mode, testedIp: ip, checks: out };
}
+31
View File
@@ -23,6 +23,7 @@ const NET = 'sp-test';
const AST = 'sp-test-ast';
const PHONES = 'sp-test-phones';
const PN_PHONE = 'sp-test-pn-phone';
const RTP_PORTS = { start: 31000, end: 31200 };
const AMI_PORT = 15038;
const AMI_SECRET = 'call-test-secret';
const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' };
@@ -79,6 +80,8 @@ function snapshot(): Snapshot {
announcements: [],
recordingAnnouncement: null,
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
// Not rtp.conf's 10000–20000: the generated range must win (RTP_PORT_START/END).
rtpPorts: RTP_PORTS,
};
}
@@ -174,6 +177,13 @@ async function main() {
ami.start();
await new Promise((r) => ami.once('connected', r));
log('AMI connected from the host');
const rtp = await ami.command('rtp show settings');
if (
!new RegExp(`Port start:\\s+${RTP_PORTS.start}\\b`).test(rtp) ||
!new RegExp(`Port end:\\s+${RTP_PORTS.end}\\b`).test(rtp)
)
problems.push(`RTP ports aren't ${RTP_PORTS.start}–${RTP_PORTS.end}:\n${rtp}`);
else log(`RTP ports ${RTP_PORTS.start}–${RTP_PORTS.end} (generated, over rtp.conf's)`);
if (RECORD) {
// A second, raw AMI session so the fixture holds exactly what Asterisk sent.
recorder = new Socket();
@@ -237,6 +247,27 @@ async function main() {
).out.trim();
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
else log('102-web over WebSocket from 127.0.0.1: registered');
// The network test probe's SIP checks (08 §2.6): Asterisk answers its OPTIONS, UDP and TCP.
const probed = docker(
'run',
'--rm',
'--network',
NET,
'-v',
`${join(import.meta.dirname, '../../../probe/src')}:/probe:ro`,
'node:24-trixie-slim',
'node',
'--input-type=module',
'-e',
`import { runTest } from '/probe/checks.ts';
for (const kind of ['sip-udp', 'sip-tcp'])
console.log(JSON.stringify(await runTest('${AST}', 'pbx.test', { id: kind, kind, port: 5060 })));`,
).out;
for (const kind of ['sip-udp', 'sip-tcp'])
if (!new RegExp(`"id":"${kind}","ok":true,"detail":"SIP/2.0 \\d{3}`).test(probed))
problems.push(`probe ${kind}: ${probed.trim()}`);
if (!problems.some((p) => p.startsWith('probe ')))
log(`probe: Asterisk answers SIP OPTIONS over UDP and TCP`);
// 101 also on an iOS Linphone: '&' in its push params used to break Dial for every device.
const pn = docker(
'run',
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System → Diagnostics (08 §2.6): the inside checks with the outside lookups faked, and the
// network test against a fake probe that really sends the audio-port datagrams.
import { createSocket } from 'node:dgram';
import type { DiagnosticCheck } from '@selfphoneng/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { insideChecks, networkTest, type DiagDeps } from '../src/services/diagnostics.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const byId = (checks: DiagnosticCheck[]) => Object.fromEntries(checks.map((c) => [c.id, c]));
describe.skipIf(!hasTestDb)('diagnostics', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let deps: DiagDeps;
beforeAll(async () => {
t = await createTestApp();
deps = {
db: t.db,
// A small range, so the busy-port scan and the samples stay quick and local.
config: { ...t.app.config, RTP_PORT_START: 41000, RTP_PORT_END: 41020 },
ami: t.app.ami,
lookupPublicIp: async () => '203.0.113.10',
resolveHost: async () => ['203.0.113.10'],
};
});
afterAll(() => t?.close());
it('flags what keeps phones off the network: no public IP, DNS elsewhere, the phone ACL', async () => {
let c = byId(await insideChecks({ ...deps, resolveHost: async () => ['198.51.100.7'] }));
expect(c['public-ip']).toMatchObject({ status: 'fail' });
expect(c['public-ip']!.fix).toContain('203.0.113.10');
expect(c.dns).toMatchObject({ status: 'fail' });
expect(c.dns!.fix).toContain('DNS only');
expect(c['phone-acl']).toMatchObject({ status: 'ok' });
expect(c.probe).toMatchObject({ status: 'skip' });
expect(c.rtp).toMatchObject({
status: 'ok',
detail: expect.stringContaining('UDP 41000–41020'),
});
await setSetting(t.db, 'network.publicIp', '203.0.113.10');
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', true);
await setSetting(t.db, 'network.lanIp', '10.9.0.5');
await setSetting(t.db, 'network.localNets', ['192.168.0.0/16']);
c = byId(await insideChecks(deps));
expect(c['public-ip']).toMatchObject({ status: 'ok', detail: '203.0.113.10' });
expect(c.dns).toMatchObject({ status: 'ok' });
expect(c['phone-acl']).toMatchObject({ status: 'warn' });
expect(c['phone-acl']!.detail).toContain('mobile data');
expect(c['local-nets']).toMatchObject({ status: 'fail' });
expect(c['local-nets']!.fix).toContain('10.9.0.0/24');
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', false);
});
it('a busy audio range is a warning that says how to move it', async () => {
const held = await Promise.all(
[41000, 41002, 41004, 41006].map(
(p) =>
new Promise<ReturnType<typeof createSocket>>((r) => {
const s = createSocket('udp4');
s.bind(p, () => r(s));
}),
),
);
const c = byId(await insideChecks(deps));
expect(c.rtp).toMatchObject({ status: 'warn' });
expect(c.rtp!.fix).toContain('RTP_PORT_START');
held.forEach((s) => s.close());
});
it('network test: maps the probe’s answers to fixes; audio ports must really get through', async () => {
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
let seen:
| { host: string; tests: { id: string; kind: string; port: number; token?: string }[] }
| undefined;
const fetchProbe = (async (_url: URL, init: RequestInit) => {
seen = JSON.parse(String(init.body));
// The firewall lets the first sampled audio port through and drops the rest.
const udp = seen!.tests.filter((x) => x.kind === 'udp');
const s = createSocket('udp4');
await new Promise<void>((r) =>
s.send(`selfphoneng-probe ${udp[0]!.token}`, udp[0]!.port, '127.0.0.1', () => r()),
);
s.close();
const ok = (id: string, detail: string) => ({ id, ok: true, detail });
const no = (id: string) => ({ id, ok: false, detail: 'no answer' });
return new Response(
JSON.stringify({
ip: '203.0.113.10',
results: [
no('http'),
ok('https', 'HTTP 200'),
no('sip-udp'),
ok('sip-tcp', 'SIP/2.0 401 Unauthorized'),
{ id: 'ws', ok: false, detail: 'HTTP 404' },
...udp.map((x) => ok(x.id, 'sent')),
],
}),
);
}) as unknown as typeof fetch;
const r = await networkTest({ ...deps, fetchProbe }, 'complete');
expect(seen!.host).toBe(t.app.config.PUBLIC_HOSTNAME);
expect(seen!.tests.map((x) => x.id)).toEqual(
expect.arrayContaining(['http', 'https', 'sip-udp', 'sip-tcp', 'ws']),
);
const c = byId(r.checks);
expect(r.testedIp).toBe('203.0.113.10');
expect(c['probe-ip']).toMatchObject({ status: 'ok' });
expect(c.http).toMatchObject({ status: 'warn' });
expect(c.https).toMatchObject({ status: 'ok' });
expect(c['sip-udp']).toMatchObject({ status: 'fail' });
expect(c['sip-udp']!.fix).toContain('SIP ALG');
expect(c['sip-tcp']).toMatchObject({ status: 'ok' });
expect(c.ws).toMatchObject({ status: 'fail' });
expect(c.rtp).toMatchObject({ status: 'warn' });
expect(c.rtp!.fix).toContain('ufw allow 41000:41020/udp');
// Quick: no audio ports, no /ws.
const quick = await networkTest(
{
...deps,
fetchProbe: (async (_u: URL, init: RequestInit) => {
seen = JSON.parse(String(init.body));
return new Response(JSON.stringify({ ip: '192.168.1.9', results: [] }));
}) as unknown as typeof fetch,
},
'quick',
);
expect(seen!.tests.map((x) => x.kind)).not.toContain('udp');
expect(byId(quick.checks)['probe-ip']).toMatchObject({ status: 'warn' }); // a private address
});
it('routes: admins only; no probe set is a 400', async () => {
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
method: 'GET',
url: '/api/system/diagnostics',
headers: { ...H, cookie },
});
expect(r.statusCode, r.body).toBe(200);
expect(r.json().checks.length).toBeGreaterThan(3);
await setSetting(t.db, 'network.probeUrl', null);
const n = await t.app.inject({
method: 'POST',
url: '/api/system/diagnostics/network',
headers: { ...H, cookie },
payload: { mode: 'quick' },
});
expect(n.statusCode).toBe(400);
expect((await t.app.inject({ method: 'GET', url: '/api/system/diagnostics' })).statusCode).toBe(
401,
);
});
});