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
@@ -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' }));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user