feat(smart-proxy): add UDP transport support with QUIC/HTTP3 routing and datagram handler relay

This commit is contained in:
2026-03-19 15:06:27 +00:00
parent cfa958cf3d
commit 4fb91cd868
34 changed files with 2978 additions and 55 deletions

View File

@@ -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.'
}

View 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;
}
}

View File

@@ -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 };

View File

@@ -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

View File

@@ -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

View File

@@ -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 };

View File

@@ -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 });
}
}

View File

@@ -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' });
}