feat(smart-proxy): add UDP transport support with QUIC/HTTP3 routing and datagram handler relay
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '25.12.0',
|
||||
version: '25.13.0',
|
||||
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
||||
}
|
||||
|
||||
239
ts/proxies/smart-proxy/datagram-handler-server.ts
Normal file
239
ts/proxies/smart-proxy/datagram-handler-server.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import type { IRouteContext } from '../../core/models/route-context.js';
|
||||
import type { RoutePreprocessor } from './route-preprocessor.js';
|
||||
import type { TDatagramHandler, IDatagramInfo } from './models/route-types.js';
|
||||
|
||||
/**
|
||||
* Framed message for datagram relay IPC.
|
||||
* Each message is length-prefixed: [4 bytes big-endian u32 length][JSON payload]
|
||||
*/
|
||||
interface IDatagramRelayMessage {
|
||||
type: 'datagram' | 'reply';
|
||||
routeKey: string;
|
||||
sourceIp: string;
|
||||
sourcePort: number;
|
||||
destPort: number;
|
||||
payloadBase64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server that receives UDP datagrams from Rust via Unix stream socket
|
||||
* and dispatches them to TypeScript datagramHandler callbacks.
|
||||
*
|
||||
* Protocol: length-prefixed JSON frames over a persistent Unix stream socket.
|
||||
* - Rust→TS: { type: "datagram", routeKey, sourceIp, sourcePort, destPort, payloadBase64 }
|
||||
* - TS→Rust: { type: "reply", sourceIp, sourcePort, destPort, payloadBase64 }
|
||||
*/
|
||||
export class DatagramHandlerServer {
|
||||
private server: plugins.net.Server | null = null;
|
||||
private connection: plugins.net.Socket | null = null;
|
||||
private socketPath: string;
|
||||
private preprocessor: RoutePreprocessor;
|
||||
private readBuffer: Buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(socketPath: string, preprocessor: RoutePreprocessor) {
|
||||
this.socketPath = socketPath;
|
||||
this.preprocessor = preprocessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening on the Unix socket.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Clean up stale socket file
|
||||
try {
|
||||
await plugins.fs.promises.unlink(this.socketPath);
|
||||
} catch {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = plugins.net.createServer((socket) => {
|
||||
this.handleConnection(socket);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.log('error', `DatagramHandlerServer error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.server.listen(this.socketPath, () => {
|
||||
logger.log('info', `DatagramHandlerServer listening on ${this.socketPath}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server and clean up.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.connection) {
|
||||
this.connection.destroy();
|
||||
this.connection = null;
|
||||
}
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.server!.close(() => resolve());
|
||||
});
|
||||
this.server = null;
|
||||
}
|
||||
try {
|
||||
await plugins.fs.promises.unlink(this.socketPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new connection from Rust.
|
||||
* Only one connection at a time (Rust maintains a persistent connection).
|
||||
*/
|
||||
private handleConnection(socket: plugins.net.Socket): void {
|
||||
if (this.connection) {
|
||||
logger.log('warn', 'DatagramHandlerServer: replacing existing connection');
|
||||
this.connection.destroy();
|
||||
}
|
||||
this.connection = socket;
|
||||
this.readBuffer = Buffer.alloc(0);
|
||||
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
|
||||
this.processFrames();
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
logger.log('error', `DatagramHandlerServer connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
if (this.connection === socket) {
|
||||
this.connection = null;
|
||||
}
|
||||
});
|
||||
|
||||
logger.log('info', 'DatagramHandlerServer: Rust relay connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process length-prefixed frames from the read buffer.
|
||||
*/
|
||||
private processFrames(): void {
|
||||
while (this.readBuffer.length >= 4) {
|
||||
const frameLen = this.readBuffer.readUInt32BE(0);
|
||||
|
||||
// Safety: reject absurdly large frames
|
||||
if (frameLen > 10 * 1024 * 1024) {
|
||||
logger.log('error', `DatagramHandlerServer: frame too large (${frameLen} bytes), resetting`);
|
||||
this.readBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.readBuffer.length < 4 + frameLen) {
|
||||
// Incomplete frame, wait for more data
|
||||
return;
|
||||
}
|
||||
|
||||
const frameData = this.readBuffer.subarray(4, 4 + frameLen);
|
||||
this.readBuffer = this.readBuffer.subarray(4 + frameLen);
|
||||
|
||||
try {
|
||||
const msg: IDatagramRelayMessage = JSON.parse(frameData.toString('utf8'));
|
||||
this.handleMessage(msg);
|
||||
} catch (err) {
|
||||
logger.log('error', `DatagramHandlerServer: failed to parse frame: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a received datagram message from Rust.
|
||||
*/
|
||||
private handleMessage(msg: IDatagramRelayMessage): void {
|
||||
if (msg.type !== 'datagram') {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalRoute = this.preprocessor.getOriginalRoute(msg.routeKey);
|
||||
if (!originalRoute) {
|
||||
logger.log('warn', `DatagramHandlerServer: no handler for route '${msg.routeKey}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler: TDatagramHandler | undefined = originalRoute.action.datagramHandler;
|
||||
if (!handler) {
|
||||
logger.log('warn', `DatagramHandlerServer: route '${msg.routeKey}' has no datagramHandler`);
|
||||
return;
|
||||
}
|
||||
|
||||
const datagram = Buffer.from(msg.payloadBase64, 'base64');
|
||||
|
||||
const context: IRouteContext = {
|
||||
port: msg.destPort,
|
||||
domain: undefined,
|
||||
clientIp: msg.sourceIp,
|
||||
serverIp: '0.0.0.0',
|
||||
path: undefined,
|
||||
isTls: false,
|
||||
tlsVersion: undefined,
|
||||
routeName: originalRoute.name,
|
||||
routeId: originalRoute.id,
|
||||
timestamp: Date.now(),
|
||||
connectionId: `udp-${msg.sourceIp}:${msg.sourcePort}-${Date.now()}`,
|
||||
};
|
||||
|
||||
const info: IDatagramInfo = {
|
||||
sourceIp: msg.sourceIp,
|
||||
sourcePort: msg.sourcePort,
|
||||
destPort: msg.destPort,
|
||||
context,
|
||||
};
|
||||
|
||||
const reply = (data: Buffer): void => {
|
||||
this.sendReply({
|
||||
type: 'reply',
|
||||
routeKey: msg.routeKey,
|
||||
sourceIp: msg.sourceIp,
|
||||
sourcePort: msg.sourcePort,
|
||||
destPort: msg.destPort,
|
||||
payloadBase64: data.toString('base64'),
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = handler(datagram, info, reply);
|
||||
if (result && typeof (result as any).catch === 'function') {
|
||||
(result as Promise<void>).catch((err) => {
|
||||
logger.log('error', `DatagramHandler error for route '${msg.routeKey}': ${err}`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('error', `DatagramHandler threw for route '${msg.routeKey}': ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply frame back to Rust.
|
||||
*/
|
||||
private sendReply(msg: IDatagramRelayMessage): void {
|
||||
if (!this.connection || this.connection.destroyed) {
|
||||
logger.log('warn', 'DatagramHandlerServer: cannot send reply, no connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(msg);
|
||||
const payload = Buffer.from(json, 'utf8');
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(payload.length, 0);
|
||||
|
||||
this.connection.write(Buffer.concat([header, payload]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the socket path for passing to Rust via IPC.
|
||||
*/
|
||||
public getSocketPath(): string {
|
||||
return this.socketPath;
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,14 @@ export interface IMetrics {
|
||||
topByErrors(limit?: number): Array<{ backend: string; errors: number }>;
|
||||
};
|
||||
|
||||
// UDP metrics
|
||||
udp: {
|
||||
activeSessions(): number;
|
||||
totalSessions(): number;
|
||||
datagramsIn(): number;
|
||||
datagramsOut(): number;
|
||||
};
|
||||
|
||||
// Performance metrics
|
||||
percentiles: {
|
||||
connectionDuration(): { p50: number; p95: number; p99: number };
|
||||
|
||||
@@ -20,9 +20,15 @@ export type TSocketHandler = (socket: plugins.net.Socket, context: IRouteContext
|
||||
export type TTlsMode = 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||
|
||||
/**
|
||||
* Port range specification format
|
||||
* Transport protocol for route matching
|
||||
*/
|
||||
export type TPortRange = number | number[] | Array<{ from: number; to: number }>;
|
||||
export type TTransportProtocol = 'tcp' | 'udp' | 'all';
|
||||
|
||||
/**
|
||||
* Port range specification format.
|
||||
* Supports: single number, array of numbers, array of ranges, or mixed arrays.
|
||||
*/
|
||||
export type TPortRange = number | Array<number | { from: number; to: number }>;
|
||||
|
||||
/**
|
||||
* Route match criteria for incoming requests
|
||||
@@ -31,6 +37,9 @@ export interface IRouteMatch {
|
||||
// Listen on these ports (required)
|
||||
ports: TPortRange;
|
||||
|
||||
// Transport protocol: 'tcp' (default), 'udp', or 'all' (both TCP and UDP)
|
||||
transport?: TTransportProtocol;
|
||||
|
||||
// Optional domain patterns to match (default: all domains)
|
||||
domains?: string | string[];
|
||||
|
||||
@@ -39,7 +48,7 @@ export interface IRouteMatch {
|
||||
clientIp?: string[]; // Match specific client IPs
|
||||
tlsVersion?: string[]; // Match specific TLS versions
|
||||
headers?: Record<string, string | RegExp>; // Match specific HTTP headers
|
||||
protocol?: 'http' | 'tcp'; // Match specific protocol (http includes h2 + websocket upgrades)
|
||||
protocol?: 'http' | 'tcp' | 'udp' | 'quic' | 'http3'; // Match specific protocol
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +81,9 @@ export interface IRouteTarget {
|
||||
headers?: IRouteHeaders; // Override route-level headers
|
||||
advanced?: IRouteAdvanced; // Override route-level advanced settings
|
||||
|
||||
// Override transport for backend connection (e.g., receive QUIC but forward as HTTP/1.1 via TCP)
|
||||
backendTransport?: 'tcp' | 'udp';
|
||||
|
||||
// Priority for matching (higher values are checked first, default: 0)
|
||||
priority?: number;
|
||||
}
|
||||
@@ -262,7 +274,7 @@ export interface IRouteAction {
|
||||
|
||||
// Additional options for backend-specific settings
|
||||
options?: {
|
||||
backendProtocol?: 'http1' | 'http2' | 'auto';
|
||||
backendProtocol?: 'http1' | 'http2' | 'http3' | 'auto';
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
@@ -274,9 +286,15 @@ export interface IRouteAction {
|
||||
|
||||
// Socket handler function (when type is 'socket-handler')
|
||||
socketHandler?: TSocketHandler;
|
||||
|
||||
|
||||
// Datagram handler function for UDP (when type is 'socket-handler' and transport is 'udp')
|
||||
datagramHandler?: TDatagramHandler;
|
||||
|
||||
// PROXY protocol support (default for all targets, can be overridden per target)
|
||||
sendProxyProtocol?: boolean;
|
||||
|
||||
// UDP-specific settings (session tracking, datagram limits, QUIC config)
|
||||
udp?: IRouteUdp;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,4 +374,64 @@ export interface IRouteConfig {
|
||||
enabled?: boolean; // Whether the route is active (default: true)
|
||||
}
|
||||
|
||||
// ─── UDP & QUIC Types ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler for individual UDP datagrams.
|
||||
* Called for each incoming datagram on a socket-handler route with UDP transport.
|
||||
*/
|
||||
export type TDatagramHandler = (
|
||||
datagram: Buffer,
|
||||
info: IDatagramInfo,
|
||||
reply: (data: Buffer) => void
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Metadata for a received UDP datagram
|
||||
*/
|
||||
export interface IDatagramInfo {
|
||||
/** Source IP address */
|
||||
sourceIp: string;
|
||||
/** Source port */
|
||||
sourcePort: number;
|
||||
/** Destination (local) port the datagram arrived on */
|
||||
destPort: number;
|
||||
/** Route context */
|
||||
context: IRouteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* UDP-specific settings for route actions
|
||||
*/
|
||||
export interface IRouteUdp {
|
||||
/** Idle timeout for a UDP session/flow (keyed by src IP:port), in ms. Default: 60000 */
|
||||
sessionTimeout?: number;
|
||||
/** Max concurrent UDP sessions per source IP. Default: 1000 */
|
||||
maxSessionsPerIP?: number;
|
||||
/** Max accepted datagram size in bytes. Oversized datagrams are dropped. Default: 65535 */
|
||||
maxDatagramSize?: number;
|
||||
/** QUIC-specific configuration. When present, traffic is treated as QUIC. */
|
||||
quic?: IRouteQuic;
|
||||
}
|
||||
|
||||
/**
|
||||
* QUIC and HTTP/3 settings
|
||||
*/
|
||||
export interface IRouteQuic {
|
||||
/** QUIC connection idle timeout in ms. Default: 30000 */
|
||||
maxIdleTimeout?: number;
|
||||
/** Max concurrent bidirectional streams per QUIC connection. Default: 100 */
|
||||
maxConcurrentBidiStreams?: number;
|
||||
/** Max concurrent unidirectional streams per QUIC connection. Default: 100 */
|
||||
maxConcurrentUniStreams?: number;
|
||||
/** Enable HTTP/3 over this QUIC endpoint. Default: false */
|
||||
enableHttp3?: boolean;
|
||||
/** Port to advertise in Alt-Svc header on TCP HTTP responses. Default: listening port */
|
||||
altSvcPort?: number;
|
||||
/** Max age for Alt-Svc advertisement in seconds. Default: 86400 */
|
||||
altSvcMaxAge?: number;
|
||||
/** Initial congestion window size in bytes. Default: implementation-defined */
|
||||
initialCongestionWindow?: number;
|
||||
}
|
||||
|
||||
// Configuration moved to models/interfaces.ts as ISmartProxyOptions
|
||||
@@ -74,6 +74,11 @@ export class RoutePreprocessor {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Datagram handler routes always need TS
|
||||
if (route.action.type === 'socket-handler' && route.action.datagramHandler) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Routes with dynamic host/port functions need TS
|
||||
if (route.action.targets) {
|
||||
for (const target of route.action.targets) {
|
||||
@@ -92,8 +97,9 @@ export class RoutePreprocessor {
|
||||
if (needsTsHandling) {
|
||||
// Convert to socket-handler type for Rust (Rust will relay back to TS)
|
||||
cleanAction.type = 'socket-handler';
|
||||
// Remove the JS handler (not serializable)
|
||||
// Remove the JS handlers (not serializable)
|
||||
delete (cleanAction as any).socketHandler;
|
||||
delete (cleanAction as any).datagramHandler;
|
||||
}
|
||||
|
||||
// Clean targets - replace functions with static values
|
||||
|
||||
@@ -218,6 +218,13 @@ export class RustMetricsAdapter implements IMetrics {
|
||||
},
|
||||
};
|
||||
|
||||
public udp = {
|
||||
activeSessions: (): number => this.cache?.activeUdpSessions ?? 0,
|
||||
totalSessions: (): number => this.cache?.totalUdpSessions ?? 0,
|
||||
datagramsIn: (): number => this.cache?.totalDatagramsIn ?? 0,
|
||||
datagramsOut: (): number => this.cache?.totalDatagramsOut ?? 0,
|
||||
};
|
||||
|
||||
public percentiles = {
|
||||
connectionDuration: (): { p50: number; p95: number; p99: number } => {
|
||||
return { p50: 0, p95: 0, p99: 0 };
|
||||
|
||||
@@ -20,6 +20,7 @@ type TSmartProxyCommands = {
|
||||
addListeningPort: { params: { port: number }; result: void };
|
||||
removeListeningPort: { params: { port: number }; result: void };
|
||||
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
|
||||
setDatagramHandlerRelay: { params: { socketPath: string }; result: void };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -177,4 +178,8 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
||||
public async loadCertificate(domain: string, cert: string, key: string, ca?: string): Promise<void> {
|
||||
await this.bridge.sendCommand('loadCertificate', { domain, cert, key, ca });
|
||||
}
|
||||
|
||||
public async setDatagramHandlerRelay(socketPath: string): Promise<void> {
|
||||
await this.bridge.sendCommand('setDatagramHandlerRelay', { socketPath });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { logger } from '../../core/utils/logger.js';
|
||||
import { RustProxyBridge } from './rust-proxy-bridge.js';
|
||||
import { RoutePreprocessor } from './route-preprocessor.js';
|
||||
import { SocketHandlerServer } from './socket-handler-server.js';
|
||||
import { DatagramHandlerServer } from './datagram-handler-server.js';
|
||||
import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
||||
|
||||
// Route management
|
||||
@@ -36,6 +37,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
private bridge: RustProxyBridge;
|
||||
private preprocessor: RoutePreprocessor;
|
||||
private socketHandlerServer: SocketHandlerServer | null = null;
|
||||
private datagramHandlerServer: DatagramHandlerServer | null = null;
|
||||
private metricsAdapter: RustMetricsAdapter;
|
||||
private routeUpdateLock: Mutex;
|
||||
private stopping = false;
|
||||
@@ -145,6 +147,16 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
await this.socketHandlerServer.start();
|
||||
}
|
||||
|
||||
// Check if any routes need datagram handler relay (UDP socket-handler routes)
|
||||
const hasDatagramHandlers = this.settings.routes.some(
|
||||
(r) => r.action.type === 'socket-handler' && r.action.datagramHandler
|
||||
);
|
||||
if (hasDatagramHandlers) {
|
||||
const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
|
||||
this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
|
||||
await this.datagramHandlerServer.start();
|
||||
}
|
||||
|
||||
// Preprocess routes (strip JS functions, convert socket-handler routes)
|
||||
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
|
||||
|
||||
@@ -167,6 +179,11 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
|
||||
}
|
||||
|
||||
// Configure datagram handler relay
|
||||
if (this.datagramHandlerServer) {
|
||||
await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
|
||||
}
|
||||
|
||||
// Load default self-signed fallback certificate (domain: '*')
|
||||
if (!this.settings.disableDefaultCert) {
|
||||
try {
|
||||
@@ -240,6 +257,12 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
this.socketHandlerServer = null;
|
||||
}
|
||||
|
||||
// Stop datagram handler relay
|
||||
if (this.datagramHandlerServer) {
|
||||
await this.datagramHandlerServer.stop();
|
||||
this.datagramHandlerServer = null;
|
||||
}
|
||||
|
||||
logger.log('info', 'SmartProxy shutdown complete.', { component: 'smart-proxy' });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user