feat(udp,http3): add UDP datagram handler relay support and stream HTTP/3 request bodies to backends
This commit is contained in:
125
test/test.datagram-handler.ts
Normal file
125
test/test.datagram-handler.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as dgram from 'dgram';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import type { TDatagramHandler, IDatagramInfo } from '../ts/index.js';
|
||||
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
||||
|
||||
let smartProxy: SmartProxy;
|
||||
let PROXY_PORT: number;
|
||||
|
||||
// Helper: send a single UDP datagram and wait for a response
|
||||
function sendDatagram(port: number, msg: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = dgram.createSocket('udp4');
|
||||
const timeout = setTimeout(() => {
|
||||
client.close();
|
||||
reject(new Error(`UDP response timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
client.send(Buffer.from(msg), port, '127.0.0.1');
|
||||
client.on('message', (data) => {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
resolve(data.toString());
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('setup: start SmartProxy with datagramHandler', async () => {
|
||||
[PROXY_PORT] = await findFreePorts(1);
|
||||
|
||||
const handler: TDatagramHandler = (datagram, info, reply) => {
|
||||
reply(Buffer.from(`Handled: ${datagram.toString()}`));
|
||||
};
|
||||
|
||||
smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
name: 'dgram-handler-test',
|
||||
match: {
|
||||
ports: PROXY_PORT,
|
||||
transport: 'udp' as const,
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
datagramHandler: handler,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await smartProxy.start();
|
||||
});
|
||||
|
||||
tap.test('datagram handler: receives and replies to datagram', async () => {
|
||||
const response = await sendDatagram(PROXY_PORT, 'Hello Handler');
|
||||
expect(response).toEqual('Handled: Hello Handler');
|
||||
});
|
||||
|
||||
tap.test('datagram handler: async handler works', async () => {
|
||||
// Stop and restart with async handler
|
||||
await smartProxy.stop();
|
||||
|
||||
[PROXY_PORT] = await findFreePorts(1);
|
||||
|
||||
const asyncHandler: TDatagramHandler = async (datagram, info, reply) => {
|
||||
// Simulate async work
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
reply(Buffer.from(`Async: ${datagram.toString()}`));
|
||||
};
|
||||
|
||||
smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
name: 'dgram-async-handler',
|
||||
match: {
|
||||
ports: PROXY_PORT,
|
||||
transport: 'udp' as const,
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
datagramHandler: asyncHandler,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await smartProxy.start();
|
||||
|
||||
const response = await sendDatagram(PROXY_PORT, 'Test Async');
|
||||
expect(response).toEqual('Async: Test Async');
|
||||
});
|
||||
|
||||
tap.test('datagram handler: multiple rapid datagrams', async () => {
|
||||
const promises: Promise<string>[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
promises.push(sendDatagram(PROXY_PORT, `msg-${i}`));
|
||||
}
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(responses).toContain(`Async: msg-${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup: stop SmartProxy', async () => {
|
||||
await smartProxy.stop();
|
||||
await assertPortsFree([PROXY_PORT]);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
142
test/test.udp-forwarding.ts
Normal file
142
test/test.udp-forwarding.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as dgram from 'dgram';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
||||
|
||||
let smartProxy: SmartProxy;
|
||||
let backendServer: dgram.Socket;
|
||||
let PROXY_PORT: number;
|
||||
let BACKEND_PORT: number;
|
||||
|
||||
// Helper: send a single UDP datagram and wait for a response
|
||||
function sendDatagram(port: number, msg: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = dgram.createSocket('udp4');
|
||||
const timeout = setTimeout(() => {
|
||||
client.close();
|
||||
reject(new Error(`UDP response timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
client.send(Buffer.from(msg), port, '127.0.0.1');
|
||||
client.on('message', (data) => {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
resolve(data.toString());
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: create a UDP echo server
|
||||
function createUdpEchoServer(port: number): Promise<dgram.Socket> {
|
||||
return new Promise((resolve) => {
|
||||
const server = dgram.createSocket('udp4');
|
||||
server.on('message', (msg, rinfo) => {
|
||||
server.send(Buffer.from(`Echo: ${msg.toString()}`), rinfo.port, rinfo.address);
|
||||
});
|
||||
server.bind(port, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('setup: start UDP echo server and SmartProxy', async () => {
|
||||
[PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
// Start backend UDP echo server
|
||||
backendServer = await createUdpEchoServer(BACKEND_PORT);
|
||||
|
||||
// Start SmartProxy with a UDP forwarding route
|
||||
smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
name: 'udp-forward-test',
|
||||
match: {
|
||||
ports: PROXY_PORT,
|
||||
transport: 'udp' as const,
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
udp: {
|
||||
sessionTimeout: 5000,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await smartProxy.start();
|
||||
});
|
||||
|
||||
tap.test('UDP forwarding: basic datagram round-trip', async () => {
|
||||
const response = await sendDatagram(PROXY_PORT, 'Hello UDP');
|
||||
expect(response).toEqual('Echo: Hello UDP');
|
||||
});
|
||||
|
||||
tap.test('UDP forwarding: multiple datagrams same session', async () => {
|
||||
// Use a single client socket for session reuse
|
||||
const client = dgram.createSocket('udp4');
|
||||
const responses: string[] = [];
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.close();
|
||||
reject(new Error('Timeout waiting for 3 responses'));
|
||||
}, 5000);
|
||||
|
||||
client.on('message', (data) => {
|
||||
responses.push(data.toString());
|
||||
if (responses.length === 3) {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
client.send(Buffer.from('msg1'), PROXY_PORT, '127.0.0.1');
|
||||
client.send(Buffer.from('msg2'), PROXY_PORT, '127.0.0.1');
|
||||
client.send(Buffer.from('msg3'), PROXY_PORT, '127.0.0.1');
|
||||
|
||||
await done;
|
||||
|
||||
expect(responses).toContain('Echo: msg1');
|
||||
expect(responses).toContain('Echo: msg2');
|
||||
expect(responses).toContain('Echo: msg3');
|
||||
});
|
||||
|
||||
tap.test('UDP forwarding: multiple clients', async () => {
|
||||
const [resp1, resp2] = await Promise.all([
|
||||
sendDatagram(PROXY_PORT, 'client1'),
|
||||
sendDatagram(PROXY_PORT, 'client2'),
|
||||
]);
|
||||
|
||||
expect(resp1).toEqual('Echo: client1');
|
||||
expect(resp2).toEqual('Echo: client2');
|
||||
});
|
||||
|
||||
tap.test('UDP forwarding: large datagram (1400 bytes)', async () => {
|
||||
const payload = 'X'.repeat(1400);
|
||||
const response = await sendDatagram(PROXY_PORT, payload);
|
||||
expect(response).toEqual(`Echo: ${payload}`);
|
||||
});
|
||||
|
||||
tap.test('cleanup: stop SmartProxy and backend', async () => {
|
||||
await smartProxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
114
test/test.udp-metrics.ts
Normal file
114
test/test.udp-metrics.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as dgram from 'dgram';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
||||
|
||||
let smartProxy: SmartProxy;
|
||||
let backendServer: dgram.Socket;
|
||||
let PROXY_PORT: number;
|
||||
let BACKEND_PORT: number;
|
||||
|
||||
// Helper: send a single UDP datagram and wait for a response
|
||||
function sendDatagram(port: number, msg: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = dgram.createSocket('udp4');
|
||||
const timeout = setTimeout(() => {
|
||||
client.close();
|
||||
reject(new Error(`UDP response timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
client.send(Buffer.from(msg), port, '127.0.0.1');
|
||||
client.on('message', (data) => {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
resolve(data.toString());
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: create a UDP echo server
|
||||
function createUdpEchoServer(port: number): Promise<dgram.Socket> {
|
||||
return new Promise((resolve) => {
|
||||
const server = dgram.createSocket('udp4');
|
||||
server.on('message', (msg, rinfo) => {
|
||||
server.send(Buffer.from(`Echo: ${msg.toString()}`), rinfo.port, rinfo.address);
|
||||
});
|
||||
server.bind(port, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('setup: start UDP echo server and SmartProxy with metrics', async () => {
|
||||
[PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
|
||||
|
||||
backendServer = await createUdpEchoServer(BACKEND_PORT);
|
||||
|
||||
smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
name: 'udp-metrics-test',
|
||||
match: {
|
||||
ports: PROXY_PORT,
|
||||
transport: 'udp' as const,
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
|
||||
udp: {
|
||||
sessionTimeout: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
enabled: true,
|
||||
sampleIntervalMs: 1000,
|
||||
retentionSeconds: 60,
|
||||
},
|
||||
});
|
||||
|
||||
await smartProxy.start();
|
||||
});
|
||||
|
||||
tap.test('UDP metrics: counters increase after traffic', async () => {
|
||||
// Send a few datagrams
|
||||
const resp1 = await sendDatagram(PROXY_PORT, 'metrics-test-1');
|
||||
expect(resp1).toEqual('Echo: metrics-test-1');
|
||||
|
||||
const resp2 = await sendDatagram(PROXY_PORT, 'metrics-test-2');
|
||||
expect(resp2).toEqual('Echo: metrics-test-2');
|
||||
|
||||
// Wait for metrics to propagate and cache to refresh
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Get metrics (returns the adapter, need to ensure cache is fresh)
|
||||
const metrics = smartProxy.getMetrics();
|
||||
|
||||
// The udp property reads from the Rust JSON snapshot
|
||||
expect(metrics.udp).toBeDefined();
|
||||
const totalSessions = metrics.udp.totalSessions();
|
||||
const datagramsIn = metrics.udp.datagramsIn();
|
||||
const datagramsOut = metrics.udp.datagramsOut();
|
||||
|
||||
console.log(`UDP metrics: sessions=${totalSessions}, in=${datagramsIn}, out=${datagramsOut}`);
|
||||
|
||||
expect(totalSessions).toBeGreaterThan(0);
|
||||
expect(datagramsIn).toBeGreaterThan(0);
|
||||
expect(datagramsOut).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('cleanup: stop SmartProxy and backend', async () => {
|
||||
await smartProxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user