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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# The network test probe (08 §2.6), run outside the network SelfPhoneNG is in. No dependencies:
|
||||
# Node 24 runs the TypeScript as it is. Build from the repo root:
|
||||
# docker build -f apps/probe/Dockerfile -t selfphoneng-probe .
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
COPY apps/probe/package.json ./
|
||||
COPY apps/probe/src/checks.ts apps/probe/src/server.ts apps/probe/src/index.ts ./src/
|
||||
ENV NODE_ENV=production
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
CMD ["node", "src/index.ts"]
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The network test probe's checks (08 §2.6). Each reaches a SelfPhoneNG server from outside its
|
||||
// network, the way a phone on mobile data or the SIP provider would, and only ever at the address
|
||||
// that asked (server.ts), so nobody can aim it at someone else.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { createSocket } from 'node:dgram';
|
||||
import { connect as tcpConnect, isIP, type Socket } from 'node:net';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
|
||||
export type Test =
|
||||
| { id: string; kind: 'tcp'; port: number }
|
||||
/** An HTTP(S) request; `websocket` asks for a SIP WebSocket upgrade (the browser phone's /ws). */
|
||||
| { id: string; kind: 'http' | 'https'; port: number; path?: string; websocket?: boolean }
|
||||
/** SIP OPTIONS: any SIP answer means Asterisk got it. */
|
||||
| { id: string; kind: 'sip-udp' | 'sip-tcp' | 'sip-tls'; port: number }
|
||||
/** Datagrams carrying `token`; the server listens and knows whether they came. */
|
||||
| { id: string; kind: 'udp'; port: number; token: string };
|
||||
|
||||
export type Result = { id: string; ok: boolean; detail: string; ms: number };
|
||||
|
||||
export const TIMEOUT_MS = 4000;
|
||||
const rand = () => randomBytes(8).toString('hex');
|
||||
|
||||
/** Sends `request` over TCP or TLS and resolves with what came back once `done` matches it. */
|
||||
function exchange(
|
||||
ip: string,
|
||||
port: number,
|
||||
opts: { tls: boolean; host: string; request: string; done: RegExp },
|
||||
): Promise<{ data: string; certError: string | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
const onConnect = () => sock.write(opts.request);
|
||||
const sock: Socket = opts.tls
|
||||
? tlsConnect(
|
||||
{
|
||||
host: ip,
|
||||
port,
|
||||
// The name to check the certificate against; SNI can't carry an IP.
|
||||
...(isIP(opts.host) ? {} : { servername: opts.host }),
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
onConnect,
|
||||
)
|
||||
: tcpConnect({ host: ip, port }, onConnect);
|
||||
const finish = (err?: Error) => {
|
||||
clearTimeout(timer);
|
||||
sock.destroy();
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const tls = sock as Socket & { authorized?: boolean; authorizationError?: Error };
|
||||
resolve({
|
||||
data,
|
||||
certError:
|
||||
opts.tls && !tls.authorized ? String(tls.authorizationError ?? 'not valid') : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error('no answer')), TIMEOUT_MS);
|
||||
sock.setEncoding('utf8');
|
||||
sock.on('data', (d: string) => {
|
||||
data += d;
|
||||
if (opts.done.test(data)) finish();
|
||||
});
|
||||
sock.on('error', (e) => finish(e));
|
||||
sock.on('close', () => (opts.done.test(data) ? finish() : finish(new Error('closed'))));
|
||||
});
|
||||
}
|
||||
|
||||
const sipOptions = (transport: string, host: string, port: number) =>
|
||||
[
|
||||
`OPTIONS sip:${host}:${port};transport=${transport.toLowerCase()} SIP/2.0`,
|
||||
// rport: answer the address and port this came from (RFC 3581), not the made-up host.
|
||||
`Via: SIP/2.0/${transport} probe.invalid;branch=z9hG4bK${rand()};rport`,
|
||||
'Max-Forwards: 70',
|
||||
`From: <sip:probe@probe.invalid>;tag=${rand()}`,
|
||||
`To: <sip:${host}>`,
|
||||
`Call-ID: ${rand()}@probe.invalid`,
|
||||
'CSeq: 1 OPTIONS',
|
||||
'User-Agent: selfphoneng-probe',
|
||||
'Content-Length: 0',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
/** OPTIONS over UDP, sent three times (UDP drops); resolves with the first answer's status line. */
|
||||
function sipUdp(ip: string, port: number, host: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
|
||||
const msg = Buffer.from(sipOptions('UDP', host, port));
|
||||
const timers = [0, 500, 1500].map((ms) =>
|
||||
setTimeout(() => sock.send(msg, port, ip, () => {}), ms),
|
||||
);
|
||||
const done = (fn: () => void) => {
|
||||
timers.forEach(clearTimeout);
|
||||
clearTimeout(timer);
|
||||
sock.close();
|
||||
fn();
|
||||
};
|
||||
const timer = setTimeout(() => done(() => reject(new Error('no answer'))), TIMEOUT_MS);
|
||||
sock.on('message', (m) => {
|
||||
const line = m.toString('utf8').split('\r\n')[0]!;
|
||||
if (line.startsWith('SIP/2.0 ')) done(() => resolve(line));
|
||||
});
|
||||
sock.on('error', (e) => done(() => reject(e)));
|
||||
});
|
||||
}
|
||||
|
||||
function sendUdp(ip: string, port: number, token: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
|
||||
const msg = Buffer.from(`selfphoneng-probe ${token}`);
|
||||
let sent = 0;
|
||||
const one = () =>
|
||||
sock.send(msg, port, ip, () => {
|
||||
if (++sent === 3) {
|
||||
sock.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
one();
|
||||
setTimeout(one, 300);
|
||||
setTimeout(one, 600);
|
||||
});
|
||||
}
|
||||
|
||||
const HTTP_STATUS = /^HTTP\/1\.[01] (\d{3})[^\r\n]*\r\n/;
|
||||
|
||||
export async function runTest(ip: string, host: string, t: Test): Promise<Result> {
|
||||
const start = Date.now();
|
||||
const result = (ok: boolean, detail: string): Result => ({
|
||||
id: t.id,
|
||||
ok,
|
||||
detail,
|
||||
ms: Date.now() - start,
|
||||
});
|
||||
try {
|
||||
switch (t.kind) {
|
||||
case 'tcp':
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const s = tcpConnect({ host: ip, port: t.port }, () => (s.destroy(), resolve()));
|
||||
s.setTimeout(TIMEOUT_MS, () => (s.destroy(), reject(new Error('no answer'))));
|
||||
s.on('error', reject);
|
||||
});
|
||||
return result(true, 'connected');
|
||||
case 'http':
|
||||
case 'https': {
|
||||
const upgrade = t.websocket
|
||||
? [
|
||||
'Connection: Upgrade',
|
||||
'Upgrade: websocket',
|
||||
`Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}`,
|
||||
'Sec-WebSocket-Version: 13',
|
||||
'Sec-WebSocket-Protocol: sip',
|
||||
]
|
||||
: ['Connection: close'];
|
||||
const request = [
|
||||
`GET ${t.path ?? '/'} HTTP/1.1`,
|
||||
`Host: ${host}`,
|
||||
'User-Agent: selfphoneng-probe',
|
||||
...upgrade,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const r = await exchange(ip, t.port, {
|
||||
tls: t.kind === 'https',
|
||||
host,
|
||||
request,
|
||||
done: HTTP_STATUS,
|
||||
});
|
||||
const status = Number(HTTP_STATUS.exec(r.data)?.[1]);
|
||||
const cert = r.certError
|
||||
? `; the certificate isn't valid for ${host} (${r.certError})`
|
||||
: '';
|
||||
if (t.websocket)
|
||||
return result(status === 101, `${status === 101 ? 'upgraded' : `HTTP ${status}`}${cert}`);
|
||||
return result(true, `HTTP ${status}${cert}`);
|
||||
}
|
||||
case 'sip-udp':
|
||||
return result(true, await sipUdp(ip, t.port, host));
|
||||
case 'sip-tcp':
|
||||
case 'sip-tls': {
|
||||
const tls = t.kind === 'sip-tls';
|
||||
const r = await exchange(ip, t.port, {
|
||||
tls,
|
||||
host,
|
||||
request: sipOptions(tls ? 'TLS' : 'TCP', host, t.port),
|
||||
done: /^SIP\/2\.0 \d{3}[^\r\n]*\r\n/,
|
||||
});
|
||||
return result(true, r.data.split('\r\n')[0]!);
|
||||
}
|
||||
case 'udp':
|
||||
await sendUdp(ip, t.port, t.token);
|
||||
return result(true, 'sent');
|
||||
}
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
return result(
|
||||
false,
|
||||
code === 'ECONNREFUSED' ? 'refused' : code ? code.toLowerCase() : (e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
|
||||
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
|
||||
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
|
||||
import { createProbe } from './server.ts';
|
||||
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
|
||||
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
|
||||
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
|
||||
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
|
||||
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
|
||||
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runTest } from './checks.ts';
|
||||
import { createProbe, parseCheck } from './server.ts';
|
||||
|
||||
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
|
||||
const listen = <T extends Server | HttpServer>(s: T) =>
|
||||
new Promise<T>((r) => s.listen(0, '127.0.0.1', () => r(s)));
|
||||
const bindUdp = (
|
||||
onMessage: (m: Buffer, from: { address: string; port: number }, s: UdpSocket) => void,
|
||||
) =>
|
||||
new Promise<UdpSocket>((r) => {
|
||||
const s = createSocket('udp4');
|
||||
s.on('message', (m, from) => onMessage(m, from, s));
|
||||
s.bind(0, '127.0.0.1', () => r(s));
|
||||
});
|
||||
const SIP_OK = (req: string) =>
|
||||
`SIP/2.0 401 Unauthorized\r\n${req
|
||||
.split('\r\n')
|
||||
.filter((l) => /^(Via|From|To|Call-ID|CSeq):/.test(l))
|
||||
.join('\r\n')}\r\nContent-Length: 0\r\n\r\n`;
|
||||
|
||||
describe('probe checks', () => {
|
||||
let http: HttpServer;
|
||||
let sipTcp: Server;
|
||||
let sipUdp: UdpSocket;
|
||||
let raw: UdpSocket;
|
||||
const got: string[] = [];
|
||||
beforeAll(async () => {
|
||||
http = await listen(httpServer((_req, res) => res.end('{"ok":true}')));
|
||||
http.on('upgrade', (req, sock) => {
|
||||
const ok = req.headers['sec-websocket-protocol'] === 'sip';
|
||||
sock.end(
|
||||
ok
|
||||
? 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'
|
||||
: 'HTTP/1.1 400 Bad Request\r\n\r\n',
|
||||
);
|
||||
});
|
||||
sipTcp = await listen(tcpServer((s) => s.on('data', (d) => s.write(SIP_OK(d.toString())))));
|
||||
sipUdp = await bindUdp((m, from, s) => s.send(SIP_OK(m.toString()), from.port, from.address));
|
||||
raw = await bindUdp((m) => got.push(m.toString()));
|
||||
});
|
||||
afterAll(() => {
|
||||
http.close();
|
||||
sipTcp.close();
|
||||
sipUdp.close();
|
||||
raw.close();
|
||||
});
|
||||
|
||||
it('TCP: connected, or refused', async () => {
|
||||
expect(
|
||||
await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: port(http) }),
|
||||
).toMatchObject({ ok: true, detail: 'connected' });
|
||||
const closed = await listen(tcpServer());
|
||||
const p = port(closed);
|
||||
closed.close();
|
||||
expect(await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: p })).toMatchObject(
|
||||
{ ok: false, detail: 'refused' },
|
||||
);
|
||||
});
|
||||
|
||||
it('HTTP status, and a SIP WebSocket upgrade', async () => {
|
||||
expect(
|
||||
await runTest('127.0.0.1', 'pbx.test', {
|
||||
id: 'h',
|
||||
kind: 'http',
|
||||
port: port(http),
|
||||
path: '/healthz',
|
||||
}),
|
||||
).toMatchObject({ ok: true, detail: 'HTTP 200' });
|
||||
expect(
|
||||
await runTest('127.0.0.1', 'pbx.test', {
|
||||
id: 'w',
|
||||
kind: 'http',
|
||||
port: port(http),
|
||||
path: '/ws',
|
||||
websocket: true,
|
||||
}),
|
||||
).toMatchObject({ ok: true, detail: 'upgraded' });
|
||||
});
|
||||
|
||||
it('SIP OPTIONS over UDP and TCP: any SIP answer', async () => {
|
||||
expect(
|
||||
await runTest('127.0.0.1', 'pbx.test', {
|
||||
id: 'u',
|
||||
kind: 'sip-udp',
|
||||
port: sipUdp.address().port,
|
||||
}),
|
||||
).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' });
|
||||
expect(
|
||||
await runTest('127.0.0.1', 'pbx.test', { id: 'c', kind: 'sip-tcp', port: port(sipTcp) }),
|
||||
).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' });
|
||||
});
|
||||
|
||||
it('UDP: the token arrives', async () => {
|
||||
expect(
|
||||
await runTest('127.0.0.1', 'pbx.test', {
|
||||
id: 'r',
|
||||
kind: 'udp',
|
||||
port: raw.address().port,
|
||||
token: 'abcd1234ef',
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(got).toContain('selfphoneng-probe abcd1234ef');
|
||||
});
|
||||
});
|
||||
|
||||
describe('probe server', () => {
|
||||
it('validates: a host, 1–20 known tests, no target of its own', () => {
|
||||
expect(parseCheck({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 443 }] })).toEqual({
|
||||
host: 'pbx.test',
|
||||
tests: [{ id: 'a', kind: 'tcp', port: 443 }],
|
||||
});
|
||||
// A target address in the body is simply not read.
|
||||
expect(
|
||||
parseCheck({
|
||||
host: 'pbx.test',
|
||||
ip: '192.0.2.1',
|
||||
tests: [{ id: 'a', kind: 'tcp', port: 1, ip: '192.0.2.1' }],
|
||||
}),
|
||||
).toEqual({
|
||||
host: 'pbx.test',
|
||||
tests: [{ id: 'a', kind: 'tcp', port: 1 }],
|
||||
});
|
||||
for (const bad of [
|
||||
{ tests: [{ id: 'a', kind: 'tcp', port: 1 }] },
|
||||
{ host: 'a b', tests: [{ id: 'a', kind: 'tcp', port: 1 }] },
|
||||
{ host: 'pbx.test', tests: [] },
|
||||
{
|
||||
host: 'pbx.test',
|
||||
tests: Array.from({ length: 21 }, (_, i) => ({ id: `a${i}`, kind: 'tcp', port: 1 })),
|
||||
},
|
||||
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'exec', port: 1 }] },
|
||||
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 70000 }] },
|
||||
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'udp', port: 1, token: 'x' }] },
|
||||
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'http', port: 1, path: 'http://evil/' }] },
|
||||
])
|
||||
expect(typeof parseCheck(bad)).toBe('string');
|
||||
});
|
||||
|
||||
it('tests the address that asked (X-Forwarded-For only behind a proxy); limits checks', async () => {
|
||||
const target = await listen(tcpServer());
|
||||
const probe = await listen(createProbe({ maxChecks: 2 }));
|
||||
const ask = () =>
|
||||
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-forwarded-for': '192.0.2.1' },
|
||||
body: JSON.stringify({
|
||||
host: 'pbx.test',
|
||||
tests: [{ id: 'tcp', kind: 'tcp', port: port(target) }],
|
||||
}),
|
||||
});
|
||||
const r = await ask();
|
||||
expect(r.status).toBe(200);
|
||||
expect(await r.json()).toMatchObject({ ip: '127.0.0.1', results: [{ id: 'tcp', ok: true }] });
|
||||
expect((await ask()).status).toBe(200);
|
||||
expect((await ask()).status).toBe(429);
|
||||
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
|
||||
probe.close();
|
||||
target.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
|
||||
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
|
||||
// from being aimed at someone else; each address gets a few checks per 10 minutes.
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import { isIP } from 'node:net';
|
||||
import { runTest, type Result, type Test } from './checks.ts';
|
||||
|
||||
export type ProbeOptions = {
|
||||
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
|
||||
trustProxy?: boolean;
|
||||
/** Checks per address per window. */
|
||||
maxChecks?: number;
|
||||
windowMs?: number;
|
||||
};
|
||||
|
||||
const HOST =
|
||||
/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
|
||||
const KINDS = new Set(['tcp', 'http', 'https', 'sip-udp', 'sip-tcp', 'sip-tls', 'udp']);
|
||||
const MAX_TESTS = 20;
|
||||
|
||||
/** The tests, or why they're not acceptable. */
|
||||
export function parseCheck(body: unknown): { host: string; tests: Test[] } | string {
|
||||
const b = body as { host?: unknown; tests?: unknown };
|
||||
if (typeof b?.host !== 'string' || !(HOST.test(b.host) || isIP(b.host)))
|
||||
return 'host: a host name';
|
||||
if (!Array.isArray(b.tests) || !b.tests.length || b.tests.length > MAX_TESTS)
|
||||
return `tests: 1 to ${MAX_TESTS}`;
|
||||
const tests: Test[] = [];
|
||||
for (const t of b.tests as Record<string, unknown>[]) {
|
||||
if (typeof t?.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(t.id)) return 'tests[].id';
|
||||
if (typeof t.kind !== 'string' || !KINDS.has(t.kind)) return `${t.id}: kind`;
|
||||
if (!Number.isInteger(t.port) || (t.port as number) < 1 || (t.port as number) > 65535)
|
||||
return `${t.id}: port`;
|
||||
if (t.path !== undefined && (typeof t.path !== 'string' || !/^\/[\w./~-]{0,100}$/.test(t.path)))
|
||||
return `${t.id}: path`;
|
||||
if (t.kind === 'udp' && (typeof t.token !== 'string' || !/^[A-Za-z0-9]{8,64}$/.test(t.token)))
|
||||
return `${t.id}: token`;
|
||||
tests.push({
|
||||
id: t.id,
|
||||
kind: t.kind,
|
||||
port: t.port,
|
||||
...(t.path !== undefined && { path: t.path }),
|
||||
...(t.websocket === true && { websocket: true }),
|
||||
...(t.kind === 'udp' && { token: t.token }),
|
||||
} as Test);
|
||||
}
|
||||
return { host: b.host, tests };
|
||||
}
|
||||
|
||||
const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
const raw =
|
||||
trustProxy && typeof xff === 'string'
|
||||
? xff.split(',').at(-1)!.trim()
|
||||
: req.socket.remoteAddress!;
|
||||
return raw.replace(/^::ffff:/, '');
|
||||
};
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > 8192) req.destroy(new Error('too big'));
|
||||
else chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function createProbe(opts: ProbeOptions = {}): Server {
|
||||
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
|
||||
// ponytail: in-process memory; addresses past the window are swept on each check.
|
||||
const seen = new Map<string, number[]>();
|
||||
const allow = (ip: string, now = Date.now()) => {
|
||||
for (const [k, v] of seen) if (now - v.at(-1)! >= windowMs) seen.delete(k);
|
||||
const list = (seen.get(ip) ?? []).filter((t) => now - t < windowMs);
|
||||
if (list.length >= maxChecks) return false;
|
||||
seen.set(ip, [...list, now]);
|
||||
return true;
|
||||
};
|
||||
const send = (res: import('node:http').ServerResponse, code: number, body: unknown) =>
|
||||
res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
||||
|
||||
return createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
|
||||
if (req.method !== 'POST' || req.url !== '/v1/check')
|
||||
return send(res, 404, { error: 'not found' });
|
||||
const ip = clientIp(req, trustProxy);
|
||||
void readJson(req)
|
||||
.then(async (body) => {
|
||||
const check = parseCheck(body);
|
||||
if (typeof check === 'string') return send(res, 400, { error: check });
|
||||
if (!allow(ip))
|
||||
return send(res, 429, { error: 'too many checks; try again in 10 minutes' });
|
||||
const results: Result[] = await Promise.all(
|
||||
check.tests.map((t) => runTest(ip, check.host, t)),
|
||||
);
|
||||
send(res, 200, { ip, results });
|
||||
})
|
||||
.catch(() => send(res, 400, { error: 'a JSON body' }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true, "types": ["node"] },
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
AboutDto,
|
||||
SystemEventDto,
|
||||
SystemStatus,
|
||||
DiagnosticsDto,
|
||||
NetworkTestDto,
|
||||
QueueReportRow,
|
||||
ReportRange,
|
||||
ReportSummary,
|
||||
@@ -239,6 +241,9 @@ export const systemApi = {
|
||||
detectPublicIp: () =>
|
||||
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
|
||||
tls: () => api<TlsCert>('GET', '/api/system/tls'),
|
||||
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
|
||||
networkTest: (mode: 'quick' | 'complete') =>
|
||||
api<NetworkTestDto>('POST', '/api/system/diagnostics/network', { mode }),
|
||||
backups: () => api<BackupDto[]>('GET', '/api/system/backups'),
|
||||
backupNow: () => api<BackupDto>('POST', '/api/system/backups'),
|
||||
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
|
||||
|
||||
@@ -216,6 +216,7 @@ function NetworkStep({ next }: { next: () => void }) {
|
||||
publicIp={s.data['network.publicIp'] ?? ''}
|
||||
lanIp={s.data['network.lanIp'] ?? status.data.lanIps[0] ?? ''}
|
||||
lanIps={status.data.lanIps}
|
||||
rtpPorts={status.data.ports.rtp}
|
||||
localNets={s.data['network.localNets'].join(', ')}
|
||||
/>
|
||||
);
|
||||
@@ -226,6 +227,7 @@ function NetworkForm(p: {
|
||||
publicIp: string;
|
||||
lanIp: string;
|
||||
lanIps: string[];
|
||||
rtpPorts: string;
|
||||
localNets: string;
|
||||
}) {
|
||||
const [publicIp, setPublicIp] = useState(p.publicIp);
|
||||
@@ -325,7 +327,7 @@ function NetworkForm(p: {
|
||||
breaks them.
|
||||
</li>
|
||||
<li>
|
||||
Forward <strong>UDP 5060</strong> and <strong>UDP 10000–20000</strong> to this server (
|
||||
Forward <strong>UDP 5060</strong> and <strong>UDP {p.rtpPorts}</strong> to this server (
|
||||
{lanIp || 'its LAN IP'}). Behind two routers, do it on both.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -417,13 +417,18 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const [ip, setIp] = useState(s['network.publicIp'] ?? '');
|
||||
const [watch, setWatch] = useState(s['network.publicIpWatch']);
|
||||
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
|
||||
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({ 'network.publicIp': ip.trim() || null, 'network.publicIpWatch': watch });
|
||||
save.mutate({
|
||||
'network.publicIp': ip.trim() || null,
|
||||
'network.publicIpWatch': watch,
|
||||
'network.probeUrl': probe.trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Field label="Public IP" hint="Changing it needs a telephony restart (see System)">
|
||||
@@ -449,6 +454,21 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
When it changes, the new IP is saved and admins get an email asking for a telephony restart.
|
||||
Asks api.ipify.org (or ifconfig.me) for the address.
|
||||
</p>
|
||||
<Field
|
||||
label="Network test probe"
|
||||
hint="A probe running outside this network (apps/probe), for System → Diagnostics. It only ever tests the address that asks it. Empty: no tests from outside."
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="url"
|
||||
placeholder="https://probe.example.com"
|
||||
className={inputClass}
|
||||
value={probe}
|
||||
onChange={(e) => setProbe(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// System (05 §2): system information, how to restart telephony, and the event log.
|
||||
// System (05 §2): system information, how to restart telephony, diagnostics, and the event log.
|
||||
// ponytail: the spec's generator runs, generated files, CLI views and audit log aren't here yet;
|
||||
// the admin guide's troubleshooting page covers them from the server's shell.
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import type { SystemEventDto } from '@selfphoneng/shared';
|
||||
import type { DiagnosticCheck, SystemEventDto } from '@selfphoneng/shared';
|
||||
import { systemApi } from '../../api/client.ts';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
|
||||
@@ -77,6 +77,105 @@ function Item({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PULSE = 'M22 12h-4l-3 9L9 3l-3 9H2';
|
||||
const STATUS: Record<DiagnosticCheck['status'], [string, string]> = {
|
||||
ok: ['OK', 'bg-green-500'],
|
||||
warn: ['Check this', 'bg-amber-500'],
|
||||
fail: ['Problem', 'bg-red-500'],
|
||||
skip: ['Not tested', 'bg-zinc-400'],
|
||||
};
|
||||
|
||||
function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) {
|
||||
return (
|
||||
<ul aria-label={label} className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{checks.map((c) => (
|
||||
<li key={c.id} className="flex gap-3 px-4 py-2 text-sm">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${STATUS[c.status][1]}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>
|
||||
<span className="font-medium">{c.label}</span>
|
||||
<span className="text-zinc-500"> · {STATUS[c.status][0]}</span>
|
||||
</p>
|
||||
<p className="break-words text-zinc-600 dark:text-zinc-400">{c.detail}</p>
|
||||
{c.fix && (
|
||||
<p className="mt-1 rounded-md bg-amber-50 px-2 py-1 break-words text-amber-900 dark:bg-amber-950/50 dark:text-amber-100">
|
||||
<strong>How to fix:</strong> {c.fix}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Diagnostics (08 §2.6): the server's own checks, and the probe's test from outside. */
|
||||
function Diagnostics() {
|
||||
const inside = useQuery({ queryKey: ['diagnostics'], queryFn: systemApi.diagnostics });
|
||||
const test = useMutation({ mutationFn: systemApi.networkTest });
|
||||
const hasProbe = inside.data?.checks.some((c) => c.id === 'probe' && c.status === 'ok');
|
||||
return (
|
||||
<Panel
|
||||
title="Diagnostics"
|
||||
path={PULSE}
|
||||
action={
|
||||
<Button onClick={() => void inside.refetch()} disabled={inside.isFetching}>
|
||||
Check again
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<h3 className="px-4 pt-3 text-sm font-medium">This server</h3>
|
||||
{inside.isPending && <p className="px-4 py-2 text-sm text-zinc-500">Checking…</p>}
|
||||
{inside.error && <Alert>{inside.error.message}</Alert>}
|
||||
{inside.data && <Checks label="Server checks" checks={inside.data.checks} />}
|
||||
<div className="flex flex-col gap-2 border-t border-zinc-200 px-4 py-3 text-sm dark:border-zinc-800">
|
||||
<h3 className="font-medium">From the internet</h3>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
The network test probe connects back to this server from outside, the way a phone on
|
||||
mobile data or your SIP provider does. Quick: ports 80, 443 and SIP. Complete: also SIP
|
||||
over TLS, the browser phone and the call audio ports.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => test.mutate('quick')}
|
||||
disabled={!hasProbe || test.isPending}
|
||||
>
|
||||
Quick network test
|
||||
</Button>
|
||||
<Button onClick={() => test.mutate('complete')} disabled={!hasProbe || test.isPending}>
|
||||
Complete network test
|
||||
</Button>
|
||||
</div>
|
||||
{inside.data && !hasProbe && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
Set a probe first:{' '}
|
||||
<Link to="/admin/settings/network" className="text-sky-700 underline dark:text-sky-400">
|
||||
Settings → Network
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{test.isPending && (
|
||||
<p role="status" className="text-zinc-500">
|
||||
Testing… (up to half a minute)
|
||||
</p>
|
||||
)}
|
||||
{test.error && <Alert>{test.error.message}</Alert>}
|
||||
</div>
|
||||
{test.data && (
|
||||
<Checks
|
||||
label={`${test.data.mode === 'quick' ? 'Quick' : 'Complete'} network test`}
|
||||
checks={test.data.checks}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const SEVERITY: Record<SystemEventDto['severity'], [string, string]> = {
|
||||
info: ['Info', 'bg-sky-500'],
|
||||
warning: ['Warning', 'bg-amber-500'],
|
||||
@@ -181,6 +280,8 @@ export function SystemPage() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Diagnostics />
|
||||
|
||||
<Panel
|
||||
title="Event log"
|
||||
path={LIST}
|
||||
|
||||
Reference in new issue
Block a user