Files
selfphoneng/apps/api/test/diagnostics.test.ts
T
iamdoubz 65a0438dde 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).
2026-10-21 12:37:16 -05:00

158 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
);
});
});