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:
1 parent
90b3081368
commit
65a0438dde
50 files changed
+1704
-59
No files matched your search
+1
-1
@@ -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'>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user