192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import { SmartProxy } from '../ts/index.js';
|
|
import * as http from 'http';
|
|
import * as net from 'net';
|
|
import * as tls from 'tls';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { assertPortsFree, findFreePorts } from './helpers/port-allocator.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const CERT_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8');
|
|
const KEY_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8');
|
|
|
|
let httpBackendPort: number;
|
|
let tlsBackendPort: number;
|
|
let httpProxyPort: number;
|
|
let tlsProxyPort: number;
|
|
|
|
let httpBackend: http.Server;
|
|
let tlsBackend: tls.Server;
|
|
let proxy: SmartProxy;
|
|
|
|
async function pollMetrics(proxyToPoll: SmartProxy): Promise<void> {
|
|
await (proxyToPoll as any).metricsAdapter.poll();
|
|
}
|
|
|
|
async function waitForCondition(
|
|
callback: () => Promise<boolean>,
|
|
timeoutMs: number = 5000,
|
|
stepMs: number = 100,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await callback()) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
}
|
|
throw new Error(`Condition not met within ${timeoutMs}ms`);
|
|
}
|
|
|
|
function hasIpDomainRequest(domain: string): boolean {
|
|
const byIp = proxy.getMetrics().connections.domainRequestsByIP();
|
|
for (const domainMap of byIp.values()) {
|
|
if (domainMap.has(domain)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
tap.test('setup - backend servers for HTTP domain rate metrics', async () => {
|
|
[httpBackendPort, tlsBackendPort, httpProxyPort, tlsProxyPort] = await findFreePorts(4);
|
|
|
|
httpBackend = http.createServer((req, res) => {
|
|
let body = '';
|
|
req.on('data', (chunk) => {
|
|
body += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end(`ok:${body}`);
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
httpBackend.listen(httpBackendPort, () => resolve());
|
|
});
|
|
|
|
tlsBackend = tls.createServer({ cert: CERT_PEM, key: KEY_PEM }, (socket) => {
|
|
socket.on('data', (data) => {
|
|
socket.write(data);
|
|
});
|
|
socket.on('error', () => {});
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
tlsBackend.listen(tlsBackendPort, () => resolve());
|
|
});
|
|
});
|
|
|
|
tap.test('setup - start proxy with HTTP and TLS passthrough routes', async () => {
|
|
proxy = new SmartProxy({
|
|
routes: [
|
|
{
|
|
id: 'http-domain-rates',
|
|
name: 'http-domain-rates',
|
|
match: { ports: httpProxyPort, domains: 'example.com' },
|
|
action: {
|
|
type: 'forward',
|
|
targets: [{ host: 'localhost', port: httpBackendPort }],
|
|
},
|
|
},
|
|
{
|
|
id: 'tls-passthrough-domain-rates',
|
|
name: 'tls-passthrough-domain-rates',
|
|
match: { ports: tlsProxyPort, domains: 'passthrough.example.com' },
|
|
action: {
|
|
type: 'forward',
|
|
tls: { mode: 'passthrough' },
|
|
targets: [{ host: 'localhost', port: tlsBackendPort }],
|
|
},
|
|
},
|
|
],
|
|
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
|
|
});
|
|
|
|
await proxy.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
});
|
|
|
|
tap.test('HTTP requests populate per-domain HTTP request rates', async () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const body = `payload-${i}`;
|
|
const req = http.request(
|
|
{
|
|
hostname: 'localhost',
|
|
port: httpProxyPort,
|
|
path: '/echo',
|
|
method: 'POST',
|
|
headers: {
|
|
Host: 'Example.COM',
|
|
'Content-Type': 'text/plain',
|
|
'Content-Length': String(body.length),
|
|
},
|
|
},
|
|
(res) => {
|
|
res.resume();
|
|
res.on('end', () => resolve());
|
|
},
|
|
);
|
|
req.on('error', reject);
|
|
req.end(body);
|
|
});
|
|
}
|
|
|
|
await waitForCondition(async () => {
|
|
await pollMetrics(proxy);
|
|
const domainMetrics = proxy.getMetrics().requests.byDomain().get('example.com');
|
|
return (domainMetrics?.lastMinute ?? 0) >= 3 && (domainMetrics?.perSecond ?? 0) > 0;
|
|
});
|
|
|
|
const exampleMetrics = proxy.getMetrics().requests.byDomain().get('example.com');
|
|
expect(exampleMetrics).toBeTruthy();
|
|
expect(exampleMetrics?.lastMinute).toEqual(3);
|
|
expect(exampleMetrics?.perSecond).toBeGreaterThan(0);
|
|
});
|
|
|
|
tap.test('TLS passthrough SNI does not inflate HTTP domain request rates', async () => {
|
|
const tlsClient = tls.connect({
|
|
host: 'localhost',
|
|
port: tlsProxyPort,
|
|
servername: 'passthrough.example.com',
|
|
rejectUnauthorized: false,
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
tlsClient.once('secureConnect', () => resolve());
|
|
tlsClient.once('error', reject);
|
|
});
|
|
|
|
const echoPromise = new Promise<void>((resolve, reject) => {
|
|
tlsClient.once('data', () => resolve());
|
|
tlsClient.once('error', reject);
|
|
});
|
|
tlsClient.write(Buffer.from('hello over tls passthrough'));
|
|
await echoPromise;
|
|
|
|
await waitForCondition(async () => {
|
|
await pollMetrics(proxy);
|
|
return hasIpDomainRequest('passthrough.example.com');
|
|
});
|
|
|
|
const requestRates = proxy.getMetrics().requests.byDomain();
|
|
expect(requestRates.has('passthrough.example.com')).toBeFalse();
|
|
expect(requestRates.get('example.com')?.lastMinute).toEqual(3);
|
|
expect(hasIpDomainRequest('passthrough.example.com')).toBeTrue();
|
|
|
|
tlsClient.destroy();
|
|
});
|
|
|
|
tap.test('cleanup - stop proxy and close backend servers', async () => {
|
|
await proxy.stop();
|
|
await new Promise<void>((resolve) => httpBackend.close(() => resolve()));
|
|
await new Promise<void>((resolve) => tlsBackend.close(() => resolve()));
|
|
await assertPortsFree([httpBackendPort, tlsBackendPort, httpProxyPort, tlsProxyPort]);
|
|
});
|
|
|
|
export default tap.start()
|