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

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

No files matched your search

+202
View File
@@ -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,
);
}
}
+10
View File
@@ -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)));
+167
View File
@@ -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();
});
});
+112
View File
@@ -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' }));
});
}