Settings → Network makes a key, shown once and stored encrypted; the api sends it to the probe as a Bearer token, and a new probe address drops it. A probe with PROBE_KEY only answers that key. Without one it stays open, and Diagnostics strongly recommends setting it.
209 lines
8.7 KiB
TypeScript
209 lines
8.7 KiB
TypeScript
// 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,
|
||
);
|
||
});
|
||
|
||
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
|
||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||
const as = (method: string, url: string, payload?: object) =>
|
||
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
|
||
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
|
||
// No key yet: the inside checks say to make one.
|
||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
|
||
|
||
const g = await as('POST', '/api/settings/network/probe-key');
|
||
expect(g.statusCode, g.body).toBe(200);
|
||
const { key } = g.json() as { key: string };
|
||
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||
const s = await as('GET', '/api/settings');
|
||
expect(s.json()['network.hasProbeKey']).toBe(true);
|
||
expect(s.body).not.toContain(key);
|
||
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
|
||
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
|
||
400,
|
||
);
|
||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
|
||
|
||
let auth: string | null = null;
|
||
await networkTest(
|
||
{
|
||
...deps,
|
||
fetchProbe: (async (_u: URL, init: RequestInit) => {
|
||
auth = new Headers(init.headers).get('authorization');
|
||
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
|
||
}) as unknown as typeof fetch,
|
||
},
|
||
'quick',
|
||
);
|
||
expect(auth).toBe(`Bearer ${key}`);
|
||
// The probe refusing it says what to do.
|
||
const refused = await networkTest(
|
||
{
|
||
...deps,
|
||
fetchProbe: (async () =>
|
||
new Response(JSON.stringify({ error: 'a key is needed' }), {
|
||
status: 401,
|
||
})) as unknown as typeof fetch,
|
||
},
|
||
'quick',
|
||
);
|
||
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
|
||
|
||
// Another address: the key was for the old probe, so it's gone.
|
||
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
|
||
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
|
||
});
|
||
});
|