Compare commits

...

8 Commits

Author SHA1 Message Date
9e1103e7a7 v25.15.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 18:55:31 +00:00
2b990527ac feat(readme): document UDP, QUIC, and HTTP/3 support in the README 2026-03-19 18:55:31 +00:00
9595f0a9fc v25.14.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 16:21:37 +00:00
0fb3988123 fix(deps): update build and runtime dependencies and align route validation test expectations 2026-03-19 16:21:37 +00:00
53938df8db v25.14.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 16:09:51 +00:00
e890bda8fc feat(udp,http3): add UDP datagram handler relay support and stream HTTP/3 request bodies to backends 2026-03-19 16:09:51 +00:00
bbe8b729ea v25.13.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 15:06:27 +00:00
4fb91cd868 feat(smart-proxy): add UDP transport support with QUIC/HTTP3 routing and datagram handler relay 2026-03-19 15:06:27 +00:00
42 changed files with 5935 additions and 2734 deletions

View File

@@ -1,5 +1,36 @@
# Changelog # Changelog
## 2026-03-19 - 25.15.0 - feat(readme)
document UDP, QUIC, and HTTP/3 support in the README
- Adds README examples for UDP datagram handlers, QUIC/HTTP3 forwarding, and dual-stack TCP/UDP routes
- Expands configuration and API reference sections to cover transport matching, UDP/QUIC options, backend transport selection, and UDP metrics
- Updates architecture and feature descriptions to reflect UDP, QUIC, HTTP/3, and datagram handler capabilities
## 2026-03-19 - 25.14.1 - fix(deps)
update build and runtime dependencies and align route validation test expectations
- split the test preparation step into a dedicated test:before script while keeping test execution separate
- bump development tooling and runtime package versions in package.json
- adjust the route validation test to match the current generic handler error message
## 2026-03-19 - 25.14.0 - feat(udp,http3)
add UDP datagram handler relay support and stream HTTP/3 request bodies to backends
- establish a persistent Unix socket relay for UDP datagram handlers and process handler replies back to clients
- update route validation and smart proxy route reload logic to support datagramHandler routes
- record UDP, QUIC, and HTTP/3 byte metrics more accurately, including request bytes in and UDP session cleanup connection tracking
- add integration tests for UDP forwarding, datagram handlers, and UDP metrics
## 2026-03-19 - 25.13.0 - feat(smart-proxy)
add UDP transport support with QUIC/HTTP3 routing and datagram handler relay
- adds UDP listener and session tracking infrastructure in the Rust proxy, including UDP metrics and hot-reload support for transport-specific ports
- introduces QUIC and HTTP/3 support in routing and HTTP handling, including Alt-Svc advertisement and QUIC TLS configuration
- extends route configuration types in Rust and TypeScript with transport, UDP, QUIC, backend transport, and mixed port range support
- adds a TypeScript datagram handler relay server and bridge command so UDP socket-handler routes can dispatch datagrams to application callbacks
- updates nftables rule generation so protocol=all creates both TCP and UDP rules
## 2026-03-19 - 25.12.0 - feat(proxy-protocol) ## 2026-03-19 - 25.12.0 - feat(proxy-protocol)
add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "25.12.0", "version": "25.15.0",
"private": false, "private": false,
"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.", "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.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -9,27 +9,28 @@
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tsrust) && (tstest test/**/test*.ts --verbose --timeout 60 --logfile)", "test:before": "(tsrust)",
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
"build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)", "build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
"format": "(gitzone format)", "format": "(gitzone format)",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.1.2", "@git.zone/tsbuild": "^4.3.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tsrust": "^1.3.0", "@git.zone/tsrust": "^1.3.0",
"@git.zone/tstest": "^3.1.8", "@git.zone/tstest": "^3.5.0",
"@push.rocks/smartserve": "^2.0.1", "@push.rocks/smartserve": "^2.0.1",
"@types/node": "^25.2.3", "@types/node": "^25.5.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"why-is-node-running": "^3.2.2" "why-is-node-running": "^3.2.2"
}, },
"dependencies": { "dependencies": {
"@push.rocks/smartcrypto": "^2.0.4", "@push.rocks/smartcrypto": "^2.0.4",
"@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartrust": "^1.2.1", "@push.rocks/smartrust": "^1.3.2",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.5.0",
"minimatch": "^10.2.0" "minimatch": "^10.2.4"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",

4777
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

211
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartproxy 🚀 # @push.rocks/smartproxy 🚀
**A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, load balancing, custom protocol handlers, and kernel-level NFTables forwarding. **A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding.
## 📦 Installation ## 📦 Installation
@@ -16,9 +16,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
## 🎯 What is SmartProxy? ## 🎯 What is SmartProxy?
SmartProxy is a production-ready proxy solution that takes the complexity out of traffic management. Under the hood, all networking — TCP, TLS, HTTP reverse proxy, connection tracking, security enforcement, and NFTables — is handled by a **Rust engine** for maximum performance, while you configure everything through a clean TypeScript API with full type safety. SmartProxy is a production-ready proxy solution that takes the complexity out of traffic management. Under the hood, all networking — TCP, UDP, TLS, HTTP reverse proxy, QUIC/HTTP3, connection tracking, security enforcement, and NFTables — is handled by a **Rust engine** for maximum performance, while you configure everything through a clean TypeScript API with full type safety.
Whether you're building microservices, deploying edge infrastructure, or need a battle-tested reverse proxy with automatic Let's Encrypt certificates, SmartProxy has you covered. Whether you're building microservices, deploying edge infrastructure, proxying UDP-based protocols, or need a battle-tested reverse proxy with automatic Let's Encrypt certificates, SmartProxy has you covered.
### ⚡ Key Features ### ⚡ Key Features
@@ -29,11 +29,12 @@ Whether you're building microservices, deploying edge infrastructure, or need a
| 🔒 **Automatic SSL/TLS** | Zero-config HTTPS with Let's Encrypt ACME integration | | 🔒 **Automatic SSL/TLS** | Zero-config HTTPS with Let's Encrypt ACME integration |
| 🎯 **Flexible Matching** | Route by port, domain, path, protocol, client IP, TLS version, headers, or custom logic | | 🎯 **Flexible Matching** | Route by port, domain, path, protocol, client IP, TLS version, headers, or custom logic |
| 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding | | 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding |
| 📡 **UDP & QUIC/HTTP3** | First-class UDP transport, datagram handlers, QUIC tunneling, and HTTP/3 support |
| ⚖️ **Load Balancing** | Round-robin, least-connections, IP-hash with health checks | | ⚖️ **Load Balancing** | Round-robin, least-connections, IP-hash with health checks |
| 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits | | 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits |
| 🔌 **WebSocket Support** | First-class WebSocket proxying with ping/pong keep-alive | | 🔌 **WebSocket Support** | First-class WebSocket proxying with ping/pong keep-alive |
| 🎮 **Custom Protocols** | Socket handlers for implementing any protocol in TypeScript | | 🎮 **Custom Protocols** | Socket and datagram handlers for implementing any protocol in TypeScript |
| 📊 **Live Metrics** | Real-time throughput, connection counts, and performance data | | 📊 **Live Metrics** | Real-time throughput, connection counts, UDP sessions, and performance data |
| 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts | | 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts |
| 🔄 **PROXY Protocol** | Full PROXY protocol v1/v2 support for preserving client information | | 🔄 **PROXY Protocol** | Full PROXY protocol v1/v2 support for preserving client information |
| 💾 **Consumer Cert Storage** | Bring your own persistence — SmartProxy never writes certs to disk | | 💾 **Consumer Cert Storage** | Bring your own persistence — SmartProxy never writes certs to disk |
@@ -89,7 +90,7 @@ SmartProxy uses a powerful **match/action** pattern that makes routing predictab
``` ```
Every route consists of: Every route consists of:
- **Match** — What traffic to capture (ports, domains, paths, protocol, IPs, headers) - **Match** — What traffic to capture (ports, domains, paths, transport, protocol, IPs, headers)
- **Action** — What to do with it (`forward` or `socket-handler`) - **Action** — What to do with it (`forward` or `socket-handler`)
- **Security** (optional) — IP allow/block lists, rate limits, authentication - **Security** (optional) — IP allow/block lists, rate limits, authentication
- **Headers** (optional) — Request/response header manipulation with template variables - **Headers** (optional) — Request/response header manipulation with template variables
@@ -197,7 +198,7 @@ apiRoute = addRateLimiting(apiRoute, {
const proxy = new SmartProxy({ routes: [apiRoute] }); const proxy = new SmartProxy({ routes: [apiRoute] });
``` ```
### 🎮 Custom Protocol Handler ### 🎮 Custom Protocol Handler (TCP)
SmartProxy lets you implement any protocol with full socket control. Routes with JavaScript socket handlers are automatically relayed from the Rust engine back to your TypeScript code: SmartProxy lets you implement any protocol with full socket control. Routes with JavaScript socket handlers are automatically relayed from the Rust engine back to your TypeScript code:
@@ -247,6 +248,98 @@ const proxy = new SmartProxy({ routes: [echoRoute, customRoute] });
| `SocketHandlers.httpBlock(status, message)` | HTTP block response | | `SocketHandlers.httpBlock(status, message)` | HTTP block response |
| `SocketHandlers.block(message)` | Block with optional message | | `SocketHandlers.block(message)` | Block with optional message |
### 📡 UDP Datagram Handler
Handle raw UDP datagrams with custom TypeScript logic — perfect for DNS, game servers, IoT protocols, or any UDP-based service:
```typescript
import { SmartProxy } from '@push.rocks/smartproxy';
import type { IRouteConfig, TDatagramHandler, IDatagramInfo } from '@push.rocks/smartproxy';
// Custom UDP echo handler
const udpHandler: TDatagramHandler = (datagram, info, reply) => {
console.log(`UDP from ${info.sourceIp}:${info.sourcePort} on port ${info.destPort}`);
reply(datagram); // Echo it back
};
const proxy = new SmartProxy({
routes: [{
name: 'udp-echo',
match: {
ports: 5353,
transport: 'udp' // 👈 Listen for UDP datagrams
},
action: {
type: 'socket-handler',
datagramHandler: udpHandler, // 👈 Process each datagram
udp: {
sessionTimeout: 60000, // Session idle timeout (ms)
maxSessionsPerIP: 100,
maxDatagramSize: 65535
}
}
}]
});
await proxy.start();
```
### 📡 QUIC / HTTP3 Forwarding
Forward QUIC traffic to backends with optional protocol translation (e.g., receive QUIC, forward as TCP/HTTP1):
```typescript
import { SmartProxy } from '@push.rocks/smartproxy';
import type { IRouteConfig } from '@push.rocks/smartproxy';
const quicRoute: IRouteConfig = {
name: 'quic-to-backend',
match: {
ports: 443,
transport: 'udp',
protocol: 'quic' // 👈 Match QUIC protocol
},
action: {
type: 'forward',
targets: [{
host: 'backend-server',
port: 8443,
backendTransport: 'tcp' // 👈 Translate QUIC → TCP for backend
}],
udp: {
quic: {
enableHttp3: true,
maxIdleTimeout: 30000,
maxConcurrentBidiStreams: 100,
altSvcPort: 443, // Advertise in Alt-Svc header
altSvcMaxAge: 86400
}
}
}
};
const proxy = new SmartProxy({ routes: [quicRoute] });
```
### 🔁 Dual-Stack TCP + UDP Route
Listen on both TCP and UDP with a single route — handle each transport with its own handler:
```typescript
const dualStackRoute: IRouteConfig = {
name: 'dual-stack-dns',
match: {
ports: 53,
transport: 'all' // 👈 Listen on both TCP and UDP
},
action: {
type: 'socket-handler',
socketHandler: handleTcpDns, // 👈 TCP connections
datagramHandler: handleUdpDns, // 👈 UDP datagrams
}
};
```
### ⚡ High-Performance NFTables Forwarding ### ⚡ High-Performance NFTables Forwarding
For ultra-low latency on Linux, use kernel-level forwarding (requires root): For ultra-low latency on Linux, use kernel-level forwarding (requires root):
@@ -419,6 +512,10 @@ console.log(`Bytes in: ${metrics.totals.bytesIn()}`);
console.log(`Requests/sec: ${metrics.requests.perSecond()}`); console.log(`Requests/sec: ${metrics.requests.perSecond()}`);
console.log(`Throughput in: ${metrics.throughput.instant().in} bytes/sec`); console.log(`Throughput in: ${metrics.throughput.instant().in} bytes/sec`);
// UDP metrics
console.log(`UDP sessions: ${metrics.udp.activeSessions()}`);
console.log(`Datagrams in: ${metrics.udp.datagramsIn()}`);
// Get detailed statistics from the Rust engine // Get detailed statistics from the Rust engine
const stats = await proxy.getStatistics(); const stats = await proxy.getStatistics();
@@ -545,7 +642,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
``` ```
┌─────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
│ Your Application │ │ Your Application │
│ (TypeScript — routes, config, socket handlers) │ │ (TypeScript — routes, config, handlers)
└──────────────────┬──────────────────────────────────┘ └──────────────────┬──────────────────────────────────┘
│ IPC (JSON over stdin/stdout) │ IPC (JSON over stdin/stdout)
┌──────────────────▼──────────────────────────────────┐ ┌──────────────────▼──────────────────────────────────┐
@@ -556,22 +653,23 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
│ │ │ │ Proxy │ │ │ │ │ │ │ │ │ │ Proxy │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │ │ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
│ │ Security│ │ Metrics │ │ Connec- │ │ NFTables │ │ │ │ UDP │ │ Security│ │ Metrics │ │ NFTables │ │
│ │ Enforce │ │ Collect │ │ tion │ │ Mgr │ │ │ │ QUIC │ │ Enforce │ │ Collect │ │ Mgr │ │
│ │ │ │ │ │ Tracker │ │ │ │ │ │ HTTP/3 │ │ │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │ │ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
└──────────────────┬──────────────────────────────────┘ └──────────────────┬──────────────────────────────────┘
│ Unix Socket Relay │ Unix Socket Relay
┌──────────────────▼──────────────────────────────────┐ ┌──────────────────▼──────────────────────────────────┐
TypeScript Socket Handler Server │ TypeScript Socket & Datagram Handler Servers
│ (for JS-defined socket handlers & dynamic routes) │ (for JS socket handlers, datagram handlers,
│ and dynamic routes) │
└─────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────┘
``` ```
- **Rust Engine** handles all networking, TLS, HTTP proxying, connection management, security, and metrics - **Rust Engine** handles all networking: TCP, UDP, TLS, QUIC, HTTP proxying, connection management, security, and metrics
- **TypeScript** provides the npm API, configuration types, route helpers, validation, and socket handler callbacks - **TypeScript** provides the npm API, configuration types, route helpers, validation, and handler callbacks
- **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary - **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary
- **Socket Relay** — A Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions) - **Socket/Datagram Relay** — Unix domain socket servers for routes requiring TypeScript-side handling (socket handlers, datagram handlers, dynamic host/port functions)
## 🎯 Route Configuration Reference ## 🎯 Route Configuration Reference
@@ -579,22 +677,26 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
```typescript ```typescript
interface IRouteMatch { interface IRouteMatch {
ports: number | number[] | Array<{ from: number; to: number }>; // Required — port(s) to listen on ports: TPortRange; // Required — port(s) to listen on
domains?: string | string[]; // 'example.com', '*.example.com' transport?: 'tcp' | 'udp' | 'all'; // Transport protocol (default: 'tcp')
path?: string; // '/api/*', '/users/:id' domains?: string | string[]; // 'example.com', '*.example.com'
clientIp?: string[]; // ['10.0.0.0/8', '192.168.*'] path?: string; // '/api/*', '/users/:id'
tlsVersion?: string[]; // ['TLSv1.2', 'TLSv1.3'] clientIp?: string[]; // ['10.0.0.0/8', '192.168.*']
tlsVersion?: string[]; // ['TLSv1.2', 'TLSv1.3']
headers?: Record<string, string | RegExp>; // Match by HTTP headers headers?: Record<string, string | RegExp>; // Match by HTTP headers
protocol?: 'http' | 'tcp'; // Match specific protocol ('http' includes h2 + WebSocket upgrades) protocol?: 'http' | 'tcp' | 'udp' | 'quic' | 'http3'; // Application-layer protocol
} }
// Port range supports single numbers, arrays, and ranges
type TPortRange = number | Array<number | { from: number; to: number }>;
``` ```
### Action Types ### Action Types
| Type | Description | | Type | Description |
|------|-------------| |------|-------------|
| `forward` | Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing) | | `forward` | Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing, UDP/QUIC) |
| `socket-handler` | Custom socket handling function in TypeScript | | `socket-handler` | Custom socket/datagram handling function in TypeScript |
### Target Options ### Target Options
@@ -602,14 +704,15 @@ interface IRouteMatch {
interface IRouteTarget { interface IRouteTarget {
host: string | string[] | ((context: IRouteContext) => string | string[]); host: string | string[] | ((context: IRouteContext) => string | string[]);
port: number | 'preserve' | ((context: IRouteContext) => number); port: number | 'preserve' | ((context: IRouteContext) => number);
tls?: IRouteTls; // Per-target TLS override tls?: IRouteTls; // Per-target TLS override
priority?: number; // Target priority priority?: number; // Target priority
match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method) match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method)
websocket?: IRouteWebSocket; websocket?: IRouteWebSocket;
loadBalancing?: IRouteLoadBalancing; loadBalancing?: IRouteLoadBalancing;
sendProxyProtocol?: boolean; sendProxyProtocol?: boolean;
headers?: IRouteHeaders; headers?: IRouteHeaders;
advanced?: IRouteAdvanced; advanced?: IRouteAdvanced;
backendTransport?: 'tcp' | 'udp'; // Backend transport (e.g., receive QUIC, forward as TCP)
} }
``` ```
@@ -666,6 +769,27 @@ interface IRouteLoadBalancing {
} }
``` ```
### UDP & QUIC Options
```typescript
interface IRouteUdp {
sessionTimeout?: number; // Idle timeout per UDP session (ms, default: 60000)
maxSessionsPerIP?: number; // Max concurrent sessions per IP (default: 1000)
maxDatagramSize?: number; // Max datagram size in bytes (default: 65535)
quic?: IRouteQuic;
}
interface IRouteQuic {
maxIdleTimeout?: number; // QUIC idle timeout (ms, default: 30000)
maxConcurrentBidiStreams?: number; // Max bidi streams (default: 100)
maxConcurrentUniStreams?: number; // Max uni streams (default: 100)
enableHttp3?: boolean; // Enable HTTP/3 (default: false)
altSvcPort?: number; // Port for Alt-Svc header
altSvcMaxAge?: number; // Alt-Svc max age in seconds (default: 86400)
initialCongestionWindow?: number; // Initial congestion window (bytes)
}
```
## 🛠️ Helper Functions Reference ## 🛠️ Helper Functions Reference
All helpers are fully typed and return `IRouteConfig` or `IRouteConfig[]`: All helpers are fully typed and return `IRouteConfig` or `IRouteConfig[]`:
@@ -689,7 +813,7 @@ import {
createWebSocketRoute, // WebSocket-enabled route createWebSocketRoute, // WebSocket-enabled route
// Custom Protocols // Custom Protocols
createSocketHandlerRoute, // Custom socket handler createSocketHandlerRoute, // Custom TCP socket handler
SocketHandlers, // Pre-built handlers (echo, proxy, block, etc.) SocketHandlers, // Pre-built handlers (echo, proxy, block, etc.)
// NFTables (Linux, requires root) // NFTables (Linux, requires root)
@@ -718,6 +842,8 @@ import {
} from '@push.rocks/smartproxy'; } from '@push.rocks/smartproxy';
``` ```
> **Tip:** For UDP datagram handler routes or QUIC/HTTP3 routes, construct `IRouteConfig` objects directly — there are no helper functions for these yet. See the [UDP Datagram Handler](#-udp-datagram-handler) and [QUIC / HTTP3 Forwarding](#-quic--http3-forwarding) examples above.
## 📖 API Documentation ## 📖 API Documentation
### SmartProxy Class ### SmartProxy Class
@@ -753,6 +879,8 @@ class SmartProxy extends EventEmitter {
// Events // Events
on(event: 'error', handler: (err: Error) => void): this; on(event: 'error', handler: (err: Error) => void): this;
on(event: 'certificate-issued', handler: (ev: ICertificateIssuedEvent) => void): this;
on(event: 'certificate-failed', handler: (ev: ICertificateFailedEvent) => void): this;
} }
``` ```
@@ -775,6 +903,8 @@ interface ISmartProxyOptions {
// Custom certificate provisioning // Custom certificate provisioning
certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>; certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>;
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true) certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true)
certProvisionTimeout?: number; // Timeout per provision call (ms)
certProvisionConcurrency?: number; // Max concurrent provisions
// Consumer-managed certificate persistence (see "Consumer-Managed Certificate Storage") // Consumer-managed certificate persistence (see "Consumer-Managed Certificate Storage")
certStore?: ISmartProxyCertStore; certStore?: ISmartProxyCertStore;
@@ -782,6 +912,9 @@ interface ISmartProxyOptions {
// Self-signed fallback // Self-signed fallback
disableDefaultCert?: boolean; // Disable '*' self-signed fallback (default: false) disableDefaultCert?: boolean; // Disable '*' self-signed fallback (default: false)
// Rust binary path override
rustBinaryPath?: string; // Custom path to the Rust proxy binary
// Global defaults // Global defaults
defaults?: { defaults?: {
target?: { host: string; port: number }; target?: { host: string; port: number };
@@ -868,11 +1001,22 @@ metrics.requests.perSecond(); // Requests per second
metrics.requests.perMinute(); // Requests per minute metrics.requests.perMinute(); // Requests per minute
metrics.requests.total(); // Total requests metrics.requests.total(); // Total requests
// UDP metrics
metrics.udp.activeSessions(); // Current active UDP sessions
metrics.udp.totalSessions(); // Total UDP sessions since start
metrics.udp.datagramsIn(); // Datagrams received
metrics.udp.datagramsOut(); // Datagrams sent
// Cumulative totals // Cumulative totals
metrics.totals.bytesIn(); // Total bytes received metrics.totals.bytesIn(); // Total bytes received
metrics.totals.bytesOut(); // Total bytes sent metrics.totals.bytesOut(); // Total bytes sent
metrics.totals.connections(); // Total connections metrics.totals.connections(); // Total connections
// Backend metrics
metrics.backends.byBackend(); // Map<backend, IBackendMetrics>
metrics.backends.protocols(); // Map<backend, protocol>
metrics.backends.topByErrors(10); // Top N error-prone backends
// Percentiles // Percentiles
metrics.percentiles.connectionDuration(); // { p50, p95, p99 } metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } } metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
@@ -896,11 +1040,12 @@ metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p5
### Rust Binary Not Found ### Rust Binary Not Found
SmartProxy searches for the Rust binary in this order: SmartProxy searches for the Rust binary in this order:
1. `SMARTPROXY_RUST_BINARY` environment variable 1. `rustBinaryPath` option in `ISmartProxyOptions`
2. Platform-specific npm package (`@push.rocks/smartproxy-linux-x64`, etc.) 2. `SMARTPROXY_RUST_BINARY` environment variable
3. `dist_rust/rustproxy` relative to the package root (built by `tsrust`) 3. Platform-specific npm package (`@push.rocks/smartproxy-linux-x64`, etc.)
4. Local dev build (`./rust/target/release/rustproxy`) 4. `dist_rust/rustproxy` relative to the package root (built by `tsrust`)
5. System PATH (`rustproxy`) 5. Local dev build (`./rust/target/release/rustproxy`)
6. System PATH (`rustproxy`)
### Performance Tuning ### Performance Tuning
- ✅ Use NFTables forwarding for high-traffic routes (Linux only) - ✅ Use NFTables forwarding for high-traffic routes (Linux only)

446
rust/Cargo.lock generated
View File

@@ -157,12 +157,24 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.57" version = "4.5.57"
@@ -218,6 +230,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -285,6 +307,24 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fastbloom"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
dependencies = [
"getrandom 0.3.4",
"libm",
"rand",
"siphasher",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -303,6 +343,21 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -310,6 +365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -318,6 +374,34 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
@@ -336,10 +420,16 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab",
] ]
[[package]] [[package]]
@@ -362,9 +452,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -392,6 +484,34 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"quinn",
"tokio",
"tokio-util",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -565,6 +685,28 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.34" version = "0.1.34"
@@ -612,6 +754,12 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libmimalloc-sys" name = "libmimalloc-sys"
version = "0.1.44" version = "0.1.44"
@@ -637,6 +785,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -784,6 +938,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -793,6 +956,64 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"futures-io",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"fastbloom",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@@ -808,6 +1029,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.13.2" version = "0.13.2"
@@ -873,6 +1123,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.36" version = "0.23.36"
@@ -916,9 +1172,37 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.9"
@@ -946,6 +1230,7 @@ dependencies = [
"mimalloc", "mimalloc",
"rcgen", "rcgen",
"rustls", "rustls",
"rustls-pemfile",
"rustproxy-config", "rustproxy-config",
"rustproxy-http", "rustproxy-http",
"rustproxy-metrics", "rustproxy-metrics",
@@ -981,10 +1266,13 @@ dependencies = [
"arc-swap", "arc-swap",
"bytes", "bytes",
"dashmap", "dashmap",
"h3",
"h3-quinn",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"quinn",
"regex", "regex",
"rustls", "rustls",
"rustproxy-config", "rustproxy-config",
@@ -1031,7 +1319,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
"base64",
"dashmap", "dashmap",
"quinn",
"rcgen",
"rustls", "rustls",
"rustls-pemfile", "rustls-pemfile",
"rustproxy-config", "rustproxy-config",
@@ -1096,6 +1387,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.28" version = "0.1.28"
@@ -1214,6 +1514,12 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1349,6 +1655,21 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@@ -1412,6 +1733,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@@ -1497,6 +1819,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -1566,12 +1898,49 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -1599,6 +1968,21 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -1632,6 +2016,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "windows_x86_64_msvc 0.53.1",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -1644,6 +2034,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1656,6 +2052,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -1680,6 +2082,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1692,6 +2100,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -1704,6 +2118,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -1716,6 +2136,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1743,6 +2169,26 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "zerocopy"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.2" version = "1.8.2"

View File

@@ -91,6 +91,13 @@ libc = "0.2"
# Socket-level options (keepalive, etc.) # Socket-level options (keepalive, etc.)
socket2 = { version = "0.5", features = ["all"] } socket2 = { version = "0.5", features = ["all"] }
# QUIC transport
quinn = "0.11"
# HTTP/3 protocol
h3 = "0.0.8"
h3-quinn = "0.0.10"
# mimalloc allocator (prevents glibc fragmentation / slow RSS growth) # mimalloc allocator (prevents glibc fragmentation / slow RSS growth)
mimalloc = "0.1" mimalloc = "0.1"

View File

@@ -15,6 +15,7 @@ pub fn create_http_route(
domains: Some(domains.into()), domains: Some(domains.into()),
path: None, path: None,
client_ip: None, client_ip: None,
transport: None,
tls_version: None, tls_version: None,
headers: None, headers: None,
protocol: None, protocol: None,
@@ -31,6 +32,7 @@ pub fn create_http_route(
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}]), }]),
tls: None, tls: None,
@@ -41,6 +43,7 @@ pub fn create_http_route(
forwarding_engine: None, forwarding_engine: None,
nftables: None, nftables: None,
send_proxy_protocol: None, send_proxy_protocol: None,
udp: None,
}, },
headers: None, headers: None,
security: None, security: None,
@@ -107,6 +110,7 @@ pub fn create_http_to_https_redirect(
domains: Some(domains), domains: Some(domains),
path: None, path: None,
client_ip: None, client_ip: None,
transport: None,
tls_version: None, tls_version: None,
headers: None, headers: None,
protocol: None, protocol: None,
@@ -137,6 +141,7 @@ pub fn create_http_to_https_redirect(
forwarding_engine: None, forwarding_engine: None,
nftables: None, nftables: None,
send_proxy_protocol: None, send_proxy_protocol: None,
udp: None,
}, },
headers: None, headers: None,
security: None, security: None,
@@ -187,6 +192,7 @@ pub fn create_load_balancer_route(
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}) })
.collect(); .collect();
@@ -200,6 +206,7 @@ pub fn create_load_balancer_route(
domains: Some(domains.into()), domains: Some(domains.into()),
path: None, path: None,
client_ip: None, client_ip: None,
transport: None,
tls_version: None, tls_version: None,
headers: None, headers: None,
protocol: None, protocol: None,
@@ -218,6 +225,7 @@ pub fn create_load_balancer_route(
forwarding_engine: None, forwarding_engine: None,
nftables: None, nftables: None,
send_proxy_protocol: None, send_proxy_protocol: None,
udp: None,
}, },
headers: None, headers: None,
security: None, security: None,

View File

@@ -7,16 +7,24 @@ use crate::security_types::RouteSecurity;
// ─── Port Range ────────────────────────────────────────────────────── // ─── Port Range ──────────────────────────────────────────────────────
/// Port range specification format. /// Port range specification format.
/// Matches TypeScript: `type TPortRange = number | number[] | Array<{ from: number; to: number }>` /// Matches TypeScript: `type TPortRange = number | Array<number | { from: number; to: number }>`
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum PortRange { pub enum PortRange {
/// Single port number /// Single port number
Single(u16), Single(u16),
/// Array of port numbers /// Array of port numbers, ranges, or mixed
List(Vec<u16>), List(Vec<PortRangeItem>),
/// Array of port ranges }
Ranges(Vec<PortRangeSpec>),
/// A single item in a port range array: either a number or a from-to range.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PortRangeItem {
/// Single port number
Port(u16),
/// A from-to port range
Range(PortRangeSpec),
} }
impl PortRange { impl PortRange {
@@ -24,9 +32,11 @@ impl PortRange {
pub fn to_ports(&self) -> Vec<u16> { pub fn to_ports(&self) -> Vec<u16> {
match self { match self {
PortRange::Single(p) => vec![*p], PortRange::Single(p) => vec![*p],
PortRange::List(ports) => ports.clone(), PortRange::List(items) => {
PortRange::Ranges(ranges) => { items.iter().flat_map(|item| match item {
ranges.iter().flat_map(|r| r.from..=r.to).collect() PortRangeItem::Port(p) => vec![*p],
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
}).collect()
} }
} }
} }
@@ -95,6 +105,10 @@ pub struct RouteMatch {
/// Listen on these ports (required) /// Listen on these ports (required)
pub ports: PortRange, pub ports: PortRange,
/// Transport protocol: tcp (default), udp, or all (both TCP and UDP)
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<TransportProtocol>,
/// Optional domain patterns to match (default: all domains) /// Optional domain patterns to match (default: all domains)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub domains: Option<DomainSpec>, pub domains: Option<DomainSpec>,
@@ -115,7 +129,7 @@ pub struct RouteMatch {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>, pub headers: Option<HashMap<String, String>>,
/// Match specific protocol: "http" (includes h2 + websocket) or "tcp" /// Match specific protocol: "http", "tcp", "udp", "quic", "http3"
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>, pub protocol: Option<String>,
} }
@@ -367,9 +381,19 @@ pub struct NfTablesOptions {
pub enum BackendProtocol { pub enum BackendProtocol {
Http1, Http1,
Http2, Http2,
Http3,
Auto, Auto,
} }
/// Transport protocol for route matching.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportProtocol {
Tcp,
Udp,
All,
}
/// Action options. /// Action options.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -470,6 +494,10 @@ pub struct RouteTarget {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub advanced: Option<RouteAdvanced>, pub advanced: Option<RouteAdvanced>,
/// Override transport for backend connection (e.g., receive QUIC but forward as TCP)
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_transport: Option<TransportProtocol>,
/// Priority for matching (higher values checked first, default: 0) /// Priority for matching (higher values checked first, default: 0)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>, pub priority: Option<i32>,
@@ -524,6 +552,68 @@ pub struct RouteAction {
/// PROXY protocol support (default for all targets) /// PROXY protocol support (default for all targets)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub send_proxy_protocol: Option<bool>, pub send_proxy_protocol: Option<bool>,
/// UDP-specific settings (session tracking, datagram limits, QUIC config)
#[serde(skip_serializing_if = "Option::is_none")]
pub udp: Option<RouteUdp>,
}
// ─── UDP & QUIC Config ──────────────────────────────────────────────
/// UDP-specific settings for route actions.
/// Matches TypeScript: `IRouteUdp`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteUdp {
/// Idle timeout for a UDP session/flow in ms. Default: 60000
#[serde(skip_serializing_if = "Option::is_none")]
pub session_timeout: Option<u64>,
/// Max concurrent UDP sessions per source IP. Default: 1000
#[serde(skip_serializing_if = "Option::is_none")]
pub max_sessions_per_ip: Option<u32>,
/// Max accepted datagram size in bytes. Default: 65535
#[serde(skip_serializing_if = "Option::is_none")]
pub max_datagram_size: Option<u32>,
/// QUIC-specific configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub quic: Option<RouteQuic>,
}
/// QUIC and HTTP/3 settings.
/// Matches TypeScript: `IRouteQuic`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteQuic {
/// QUIC connection idle timeout in ms. Default: 30000
#[serde(skip_serializing_if = "Option::is_none")]
pub max_idle_timeout: Option<u64>,
/// Max concurrent bidirectional streams per QUIC connection. Default: 100
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent_bidi_streams: Option<u32>,
/// Max concurrent unidirectional streams per QUIC connection. Default: 100
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent_uni_streams: Option<u32>,
/// Enable HTTP/3 over this QUIC endpoint. Default: false
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_http3: Option<bool>,
/// Port to advertise in Alt-Svc header on TCP HTTP responses
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_svc_port: Option<u16>,
/// Max age for Alt-Svc advertisement in seconds. Default: 86400
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_svc_max_age: Option<u64>,
/// Initial congestion window size in bytes
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_congestion_window: Option<u32>,
} }
// ─── Route Config ──────────────────────────────────────────────────── // ─── Route Config ────────────────────────────────────────────────────

View File

@@ -27,3 +27,6 @@ arc-swap = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
socket2 = { workspace = true } socket2 = { workspace = true }
quinn = { workspace = true }
h3 = { workspace = true }
h3-quinn = { workspace = true }

View File

@@ -1,7 +1,7 @@
//! Backend connection pool for HTTP/1.1 and HTTP/2. //! Backend connection pool for HTTP/1.1, HTTP/2, and HTTP/3 (QUIC).
//! //!
//! Reuses idle keep-alive connections to avoid per-request TCP+TLS handshakes. //! Reuses idle keep-alive connections to avoid per-request TCP+TLS handshakes.
//! HTTP/2 connections are multiplexed (clone the sender for each request). //! HTTP/2 and HTTP/3 connections are multiplexed (clone the sender / share the connection).
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -19,9 +19,17 @@ const IDLE_TIMEOUT: Duration = Duration::from_secs(90);
/// Background eviction interval. /// Background eviction interval.
const EVICTION_INTERVAL: Duration = Duration::from_secs(30); const EVICTION_INTERVAL: Duration = Duration::from_secs(30);
/// Maximum age for pooled HTTP/2 connections before proactive eviction. /// Maximum age for pooled HTTP/2 connections before proactive eviction.
/// Prevents staleness from backends that close idle connections (e.g. nginx GOAWAY).
/// 120s is well within typical server GOAWAY windows (nginx: ~60s idle, envoy: ~60s).
const MAX_H2_AGE: Duration = Duration::from_secs(120); const MAX_H2_AGE: Duration = Duration::from_secs(120);
/// Maximum age for pooled QUIC/HTTP/3 connections.
const MAX_H3_AGE: Duration = Duration::from_secs(120);
/// Protocol for pool key discrimination.
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub enum PoolProtocol {
H1,
H2,
H3,
}
/// Identifies a unique backend endpoint. /// Identifies a unique backend endpoint.
#[derive(Clone, Debug, Hash, Eq, PartialEq)] #[derive(Clone, Debug, Hash, Eq, PartialEq)]
@@ -29,7 +37,7 @@ pub struct PoolKey {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub use_tls: bool, pub use_tls: bool,
pub h2: bool, pub protocol: PoolProtocol,
} }
/// An idle HTTP/1.1 sender with a timestamp for eviction. /// An idle HTTP/1.1 sender with a timestamp for eviction.
@@ -47,13 +55,22 @@ struct PooledH2 {
generation: u64, generation: u64,
} }
/// A pooled QUIC/HTTP/3 connection (multiplexed like H2).
pub struct PooledH3 {
pub connection: quinn::Connection,
pub created_at: Instant,
pub generation: u64,
}
/// Backend connection pool. /// Backend connection pool.
pub struct ConnectionPool { pub struct ConnectionPool {
/// HTTP/1.1 idle connections indexed by backend key. /// HTTP/1.1 idle connections indexed by backend key.
h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>, h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>,
/// HTTP/2 multiplexed connections indexed by backend key. /// HTTP/2 multiplexed connections indexed by backend key.
h2_pool: Arc<DashMap<PoolKey, PooledH2>>, h2_pool: Arc<DashMap<PoolKey, PooledH2>>,
/// Monotonic generation counter for H2 pool entries. /// HTTP/3 (QUIC) connections indexed by backend key.
h3_pool: Arc<DashMap<PoolKey, PooledH3>>,
/// Monotonic generation counter for H2/H3 pool entries.
h2_generation: AtomicU64, h2_generation: AtomicU64,
/// Handle for the background eviction task. /// Handle for the background eviction task.
eviction_handle: Option<tokio::task::JoinHandle<()>>, eviction_handle: Option<tokio::task::JoinHandle<()>>,
@@ -64,16 +81,19 @@ impl ConnectionPool {
pub fn new() -> Self { pub fn new() -> Self {
let h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>> = Arc::new(DashMap::new()); let h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>> = Arc::new(DashMap::new());
let h2_pool: Arc<DashMap<PoolKey, PooledH2>> = Arc::new(DashMap::new()); let h2_pool: Arc<DashMap<PoolKey, PooledH2>> = Arc::new(DashMap::new());
let h3_pool: Arc<DashMap<PoolKey, PooledH3>> = Arc::new(DashMap::new());
let h1_clone = Arc::clone(&h1_pool); let h1_clone = Arc::clone(&h1_pool);
let h2_clone = Arc::clone(&h2_pool); let h2_clone = Arc::clone(&h2_pool);
let h3_clone = Arc::clone(&h3_pool);
let eviction_handle = tokio::spawn(async move { let eviction_handle = tokio::spawn(async move {
Self::eviction_loop(h1_clone, h2_clone).await; Self::eviction_loop(h1_clone, h2_clone, h3_clone).await;
}); });
Self { Self {
h1_pool, h1_pool,
h2_pool, h2_pool,
h3_pool,
h2_generation: AtomicU64::new(0), h2_generation: AtomicU64::new(0),
eviction_handle: Some(eviction_handle), eviction_handle: Some(eviction_handle),
} }
@@ -173,10 +193,57 @@ impl ConnectionPool {
gen gen
} }
// ── HTTP/3 (QUIC) pool methods ──
/// Try to get a pooled QUIC connection for the given key.
/// QUIC connections are multiplexed — the connection is shared, not removed.
pub fn checkout_h3(&self, key: &PoolKey) -> Option<(quinn::Connection, Duration)> {
let entry = self.h3_pool.get(key)?;
let pooled = entry.value();
let age = pooled.created_at.elapsed();
if age >= MAX_H3_AGE {
drop(entry);
self.h3_pool.remove(key);
return None;
}
// Check if QUIC connection is still alive
if pooled.connection.close_reason().is_some() {
drop(entry);
self.h3_pool.remove(key);
return None;
}
Some((pooled.connection.clone(), age))
}
/// Register a QUIC connection in the pool. Returns the generation ID.
pub fn register_h3(&self, key: PoolKey, connection: quinn::Connection) -> u64 {
let gen = self.h2_generation.fetch_add(1, Ordering::Relaxed);
self.h3_pool.insert(key, PooledH3 {
connection,
created_at: Instant::now(),
generation: gen,
});
gen
}
/// Remove a QUIC connection only if generation matches.
pub fn remove_h3_if_generation(&self, key: &PoolKey, expected_gen: u64) {
if let Some(entry) = self.h3_pool.get(key) {
if entry.value().generation == expected_gen {
drop(entry);
self.h3_pool.remove(key);
}
}
}
/// Background eviction loop — runs every EVICTION_INTERVAL to remove stale connections. /// Background eviction loop — runs every EVICTION_INTERVAL to remove stale connections.
async fn eviction_loop( async fn eviction_loop(
h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>, h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>,
h2_pool: Arc<DashMap<PoolKey, PooledH2>>, h2_pool: Arc<DashMap<PoolKey, PooledH2>>,
h3_pool: Arc<DashMap<PoolKey, PooledH3>>,
) { ) {
let mut interval = tokio::time::interval(EVICTION_INTERVAL); let mut interval = tokio::time::interval(EVICTION_INTERVAL);
loop { loop {
@@ -206,6 +273,19 @@ impl ConnectionPool {
for key in dead_h2 { for key in dead_h2 {
h2_pool.remove(&key); h2_pool.remove(&key);
} }
// Evict dead or aged-out H3 (QUIC) connections
let mut dead_h3 = Vec::new();
for entry in h3_pool.iter() {
if entry.value().connection.close_reason().is_some()
|| entry.value().created_at.elapsed() >= MAX_H3_AGE
{
dead_h3.push(entry.key().clone());
}
}
for key in dead_h3 {
h3_pool.remove(&key);
}
} }
} }
} }

View File

@@ -0,0 +1,332 @@
//! HTTP/3 proxy service.
//!
//! Accepts QUIC connections via quinn, runs h3 server to handle HTTP/3 requests,
//! and forwards them to backends using the same routing and pool infrastructure
//! as the HTTP/1+2 proxy.
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use arc_swap::ArcSwap;
use bytes::{Buf, Bytes};
use http_body::Frame;
use tracing::{debug, warn};
use rustproxy_config::{RouteConfig, TransportProtocol};
use rustproxy_metrics::MetricsCollector;
use rustproxy_routing::{MatchContext, RouteManager};
use crate::connection_pool::ConnectionPool;
use crate::protocol_cache::ProtocolCache;
use crate::upstream_selector::UpstreamSelector;
/// HTTP/3 proxy service.
///
/// Handles QUIC connections with the h3 crate, parses HTTP/3 requests,
/// and forwards them to backends using per-request route matching and
/// shared connection pooling.
pub struct H3ProxyService {
route_manager: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>,
connection_pool: Arc<ConnectionPool>,
#[allow(dead_code)]
protocol_cache: Arc<ProtocolCache>,
#[allow(dead_code)]
upstream_selector: UpstreamSelector,
#[allow(dead_code)]
backend_tls_config: Arc<rustls::ClientConfig>,
connect_timeout: Duration,
}
impl H3ProxyService {
pub fn new(
route_manager: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>,
connection_pool: Arc<ConnectionPool>,
protocol_cache: Arc<ProtocolCache>,
backend_tls_config: Arc<rustls::ClientConfig>,
connect_timeout: Duration,
) -> Self {
Self {
route_manager: Arc::clone(&route_manager),
metrics: Arc::clone(&metrics),
connection_pool,
protocol_cache,
upstream_selector: UpstreamSelector::new(),
backend_tls_config,
connect_timeout,
}
}
/// Handle an accepted QUIC connection as HTTP/3.
pub async fn handle_connection(
&self,
connection: quinn::Connection,
_fallback_route: &RouteConfig,
port: u16,
) -> anyhow::Result<()> {
let remote_addr = connection.remote_address();
debug!("HTTP/3 connection from {} on port {}", remote_addr, port);
let mut h3_conn: h3::server::Connection<h3_quinn::Connection, Bytes> =
h3::server::Connection::new(h3_quinn::Connection::new(connection))
.await
.map_err(|e| anyhow::anyhow!("H3 connection setup failed: {}", e))?;
let client_ip = remote_addr.ip().to_string();
loop {
match h3_conn.accept().await {
Ok(Some(resolver)) => {
let (request, stream) = match resolver.resolve_request().await {
Ok(pair) => pair,
Err(e) => {
debug!("HTTP/3 request resolve error: {}", e);
continue;
}
};
self.metrics.record_http_request();
let rm = self.route_manager.load();
let pool = Arc::clone(&self.connection_pool);
let metrics = Arc::clone(&self.metrics);
let connect_timeout = self.connect_timeout;
let client_ip = client_ip.clone();
tokio::spawn(async move {
if let Err(e) = handle_h3_request(
request, stream, port, &client_ip, &rm, &pool, &metrics, connect_timeout,
).await {
debug!("HTTP/3 request error from {}: {}", client_ip, e);
}
});
}
Ok(None) => {
debug!("HTTP/3 connection from {} closed", remote_addr);
break;
}
Err(e) => {
debug!("HTTP/3 accept error from {}: {}", remote_addr, e);
break;
}
}
}
Ok(())
}
}
/// Handle a single HTTP/3 request with per-request route matching.
async fn handle_h3_request(
request: hyper::Request<()>,
mut stream: h3::server::RequestStream<h3_quinn::BidiStream<Bytes>, Bytes>,
port: u16,
client_ip: &str,
route_manager: &RouteManager,
_connection_pool: &ConnectionPool,
metrics: &MetricsCollector,
connect_timeout: Duration,
) -> anyhow::Result<()> {
let method = request.method().clone();
let uri = request.uri().clone();
let path = uri.path().to_string();
// Extract host from :authority or Host header
let host = request.uri().authority()
.map(|a| a.as_str().to_string())
.or_else(|| request.headers().get("host").and_then(|v| v.to_str().ok()).map(|s| s.to_string()))
.unwrap_or_default();
debug!("HTTP/3 {} {} (host: {}, client: {})", method, path, host, client_ip);
// Per-request route matching
let ctx = MatchContext {
port,
domain: if host.is_empty() { None } else { Some(&host) },
path: Some(&path),
client_ip: Some(client_ip),
tls_version: Some("TLSv1.3"),
headers: None,
is_tls: true,
protocol: Some("http"),
transport: Some(TransportProtocol::Udp),
};
let route_match = route_manager.find_route(&ctx)
.ok_or_else(|| anyhow::anyhow!("No route matched for HTTP/3 request to {}{}", host, path))?;
let route = route_match.route;
// Resolve backend target (use matched target or first target)
let target = route_match.target
.or_else(|| route.action.targets.as_ref().and_then(|t| t.first()))
.ok_or_else(|| anyhow::anyhow!("No target for HTTP/3 route"))?;
let backend_host = target.host.first();
let backend_port = target.port.resolve(port);
let backend_addr = format!("{}:{}", backend_host, backend_port);
// Connect to backend via TCP HTTP/1.1 with timeout
let tcp_stream = tokio::time::timeout(
connect_timeout,
tokio::net::TcpStream::connect(&backend_addr),
).await
.map_err(|_| anyhow::anyhow!("Backend connect timeout to {}", backend_addr))?
.map_err(|e| anyhow::anyhow!("Backend connect to {} failed: {}", backend_addr, e))?;
let _ = tcp_stream.set_nodelay(true);
let io = hyper_util::rt::TokioIo::new(tcp_stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await
.map_err(|e| anyhow::anyhow!("Backend handshake failed: {}", e))?;
tokio::spawn(async move {
if let Err(e) = conn.await {
debug!("Backend connection closed: {}", e);
}
});
// Stream request body from H3 client to backend via an mpsc channel.
// This avoids buffering the entire request body in memory.
let (body_tx, body_rx) = tokio::sync::mpsc::channel::<Bytes>(4);
let total_bytes_in = Arc::new(std::sync::atomic::AtomicU64::new(0));
let total_bytes_in_writer = Arc::clone(&total_bytes_in);
// Spawn the H3 body reader task
let body_reader = tokio::spawn(async move {
while let Ok(Some(mut chunk)) = stream.recv_data().await {
let data = Bytes::copy_from_slice(chunk.chunk());
total_bytes_in_writer.fetch_add(data.len() as u64, std::sync::atomic::Ordering::Relaxed);
chunk.advance(chunk.remaining());
if body_tx.send(data).await.is_err() {
break;
}
}
stream
});
// Create a body that polls from the mpsc receiver
let body = H3RequestBody { receiver: body_rx };
let backend_req = build_backend_request(&method, &backend_addr, &path, &host, &request, body)?;
let response = sender.send_request(backend_req).await
.map_err(|e| anyhow::anyhow!("Backend request failed: {}", e))?;
// Await the body reader to get the stream back
let mut stream = body_reader.await
.map_err(|e| anyhow::anyhow!("Body reader task failed: {}", e))?;
let total_bytes_in = total_bytes_in.load(std::sync::atomic::Ordering::Relaxed);
// Build H3 response
let status = response.status();
let mut h3_response = hyper::Response::builder().status(status);
// Copy response headers (skip hop-by-hop)
for (name, value) in response.headers() {
let n = name.as_str().to_lowercase();
if n == "transfer-encoding" || n == "connection" || n == "keep-alive" || n == "upgrade" {
continue;
}
h3_response = h3_response.header(name, value);
}
// Add Alt-Svc for HTTP/3 advertisement
let alt_svc = route.action.udp.as_ref()
.and_then(|u| u.quic.as_ref())
.map(|q| {
let p = q.alt_svc_port.unwrap_or(port);
let ma = q.alt_svc_max_age.unwrap_or(86400);
format!("h3=\":{}\"; ma={}", p, ma)
})
.unwrap_or_else(|| format!("h3=\":{}\"; ma=86400", port));
h3_response = h3_response.header("alt-svc", alt_svc);
let h3_response = h3_response.body(())
.map_err(|e| anyhow::anyhow!("Failed to build H3 response: {}", e))?;
// Send response headers
stream.send_response(h3_response).await
.map_err(|e| anyhow::anyhow!("Failed to send H3 response: {}", e))?;
// Stream response body back
use http_body_util::BodyExt;
let mut body = response.into_body();
let mut total_bytes_out: u64 = 0;
while let Some(frame) = body.frame().await {
match frame {
Ok(frame) => {
if let Some(data) = frame.data_ref() {
total_bytes_out += data.len() as u64;
stream.send_data(Bytes::copy_from_slice(data)).await
.map_err(|e| anyhow::anyhow!("Failed to send H3 data: {}", e))?;
}
}
Err(e) => {
warn!("Backend body read error: {}", e);
break;
}
}
}
// Record metrics
let route_id = route.name.as_deref().or(route.id.as_deref());
metrics.record_bytes(total_bytes_in, total_bytes_out, route_id, Some(client_ip));
// Finish the stream
stream.finish().await
.map_err(|e| anyhow::anyhow!("Failed to finish H3 stream: {}", e))?;
Ok(())
}
/// Build an HTTP/1.1 backend request from the H3 frontend request.
fn build_backend_request<B>(
method: &hyper::Method,
backend_addr: &str,
path: &str,
host: &str,
original_request: &hyper::Request<()>,
body: B,
) -> anyhow::Result<hyper::Request<B>> {
let mut req = hyper::Request::builder()
.method(method)
.uri(format!("http://{}{}", backend_addr, path))
.header("host", host);
// Forward non-pseudo headers
for (name, value) in original_request.headers() {
let n = name.as_str();
if !n.starts_with(':') && n != "host" {
req = req.header(name, value);
}
}
req.body(body)
.map_err(|e| anyhow::anyhow!("Failed to build backend request: {}", e))
}
/// A streaming request body backed by an mpsc channel receiver.
///
/// Implements `http_body::Body` so hyper can poll chunks as they arrive
/// from the H3 client, avoiding buffering the entire request body in memory.
struct H3RequestBody {
receiver: tokio::sync::mpsc::Receiver<Bytes>,
}
impl http_body::Body for H3RequestBody {
type Data = Bytes;
type Error = hyper::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
match self.receiver.poll_recv(cx) {
Poll::Ready(Some(data)) => Poll::Ready(Some(Ok(Frame::data(data)))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}

View File

@@ -12,6 +12,7 @@ pub mod response_filter;
pub mod shutdown_on_drop; pub mod shutdown_on_drop;
pub mod template; pub mod template;
pub mod upstream_selector; pub mod upstream_selector;
pub mod h3_service;
pub use connection_pool::*; pub use connection_pool::*;
pub use counting_body::*; pub use counting_body::*;

View File

@@ -26,6 +26,7 @@ const PROTOCOL_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
pub enum DetectedProtocol { pub enum DetectedProtocol {
H1, H1,
H2, H2,
H3,
} }
/// Key for the protocol cache: (host, port, requested_host). /// Key for the protocol cache: (host, port, requested_host).

View File

@@ -451,6 +451,7 @@ impl HttpProxyService {
headers: headers.as_ref(), headers: headers.as_ref(),
is_tls: false, is_tls: false,
protocol: Some("http"), protocol: Some("http"),
transport: None,
}; };
let route_match = match current_rm.find_route(&ctx) { let route_match = match current_rm.find_route(&ctx) {
@@ -647,6 +648,11 @@ impl HttpProxyService {
let (use_h2, needs_alpn_probe) = match backend_protocol_mode { let (use_h2, needs_alpn_probe) = match backend_protocol_mode {
rustproxy_config::BackendProtocol::Http1 => (false, false), rustproxy_config::BackendProtocol::Http1 => (false, false),
rustproxy_config::BackendProtocol::Http2 => (true, false), rustproxy_config::BackendProtocol::Http2 => (true, false),
rustproxy_config::BackendProtocol::Http3 => {
// HTTP/3 (QUIC) backend connections not yet implemented — fall back to H1
warn!("backendProtocol 'http3' not yet implemented, falling back to http1");
(false, false)
}
rustproxy_config::BackendProtocol::Auto => { rustproxy_config::BackendProtocol::Auto => {
if !upstream.use_tls { if !upstream.use_tls {
// No ALPN without TLS — default to H1 // No ALPN without TLS — default to H1
@@ -660,6 +666,10 @@ impl HttpProxyService {
match self.protocol_cache.get(&cache_key) { match self.protocol_cache.get(&cache_key) {
Some(crate::protocol_cache::DetectedProtocol::H2) => (true, false), Some(crate::protocol_cache::DetectedProtocol::H2) => (true, false),
Some(crate::protocol_cache::DetectedProtocol::H1) => (false, false), Some(crate::protocol_cache::DetectedProtocol::H1) => (false, false),
Some(crate::protocol_cache::DetectedProtocol::H3) => {
// H3 cached but we're on TCP — fall back to H2 probe
(false, true)
}
None => (false, true), // needs ALPN probe None => (false, true), // needs ALPN probe
} }
} }
@@ -673,7 +683,7 @@ impl HttpProxyService {
host: upstream.host.clone(), host: upstream.host.clone(),
port: upstream.port, port: upstream.port,
use_tls: upstream.use_tls, use_tls: upstream.use_tls,
h2: use_h2, protocol: if use_h2 { crate::connection_pool::PoolProtocol::H2 } else { crate::connection_pool::PoolProtocol::H1 },
}; };
// H2 pool checkout — reuse pooled connections for all requests. // H2 pool checkout — reuse pooled connections for all requests.
@@ -832,7 +842,7 @@ impl HttpProxyService {
host: upstream.host.clone(), host: upstream.host.clone(),
port: upstream.port, port: upstream.port,
use_tls: upstream.use_tls, use_tls: upstream.use_tls,
h2: detected_h2, protocol: if detected_h2 { crate::connection_pool::PoolProtocol::H2 } else { crate::connection_pool::PoolProtocol::H1 },
}; };
let io = TokioIo::new(backend); let io = TokioIo::new(backend);
@@ -1298,7 +1308,7 @@ impl HttpProxyService {
host: upstream.host.clone(), host: upstream.host.clone(),
port: upstream.port, port: upstream.port,
use_tls: upstream.use_tls, use_tls: upstream.use_tls,
h2: false, protocol: crate::connection_pool::PoolProtocol::H1,
}; };
let fallback_io = TokioIo::new(fallback_backend); let fallback_io = TokioIo::new(fallback_backend);
let result = self.forward_h1( let result = self.forward_h1(
@@ -1438,7 +1448,7 @@ impl HttpProxyService {
host: upstream.host.clone(), host: upstream.host.clone(),
port: upstream.port, port: upstream.port,
use_tls: upstream.use_tls, use_tls: upstream.use_tls,
h2: false, protocol: crate::connection_pool::PoolProtocol::H1,
}; };
let fallback_io = TokioIo::new(fallback_backend); let fallback_io = TokioIo::new(fallback_backend);
let result = self.forward_h1( let result = self.forward_h1(

View File

@@ -10,7 +10,23 @@ pub struct ResponseFilter;
impl ResponseFilter { impl ResponseFilter {
/// Apply response headers from route config and CORS settings. /// Apply response headers from route config and CORS settings.
/// If a `RequestContext` is provided, template variables in header values will be expanded. /// If a `RequestContext` is provided, template variables in header values will be expanded.
/// Also injects Alt-Svc header for routes with HTTP/3 enabled.
pub fn apply_headers(route: &RouteConfig, headers: &mut HeaderMap, req_ctx: Option<&RequestContext>) { pub fn apply_headers(route: &RouteConfig, headers: &mut HeaderMap, req_ctx: Option<&RequestContext>) {
// Inject Alt-Svc for HTTP/3 advertisement if QUIC/HTTP3 is enabled on this route
if let Some(ref udp) = route.action.udp {
if let Some(ref quic) = udp.quic {
if quic.enable_http3.unwrap_or(false) {
let port = quic.alt_svc_port
.or_else(|| req_ctx.map(|c| c.port))
.unwrap_or(443);
let max_age = quic.alt_svc_max_age.unwrap_or(86400);
let alt_svc = format!("h3=\":{}\"; ma={}", port, max_age);
if let Ok(val) = HeaderValue::from_str(&alt_svc) {
headers.insert("alt-svc", val);
}
}
}
}
// Apply custom response headers from route config // Apply custom response headers from route config
if let Some(ref route_headers) = route.headers { if let Some(ref route_headers) = route.headers {
if let Some(ref response_headers) = route_headers.response { if let Some(ref response_headers) = route_headers.response {

View File

@@ -184,6 +184,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
} }
} }

View File

@@ -26,6 +26,11 @@ pub struct Metrics {
pub total_http_requests: u64, pub total_http_requests: u64,
pub http_requests_per_sec: u64, pub http_requests_per_sec: u64,
pub http_requests_per_sec_recent: u64, pub http_requests_per_sec_recent: u64,
// UDP metrics
pub active_udp_sessions: u64,
pub total_udp_sessions: u64,
pub total_datagrams_in: u64,
pub total_datagrams_out: u64,
} }
/// Per-route metrics. /// Per-route metrics.
@@ -136,6 +141,12 @@ pub struct MetricsCollector {
pending_http_requests: AtomicU64, pending_http_requests: AtomicU64,
http_request_throughput: Mutex<ThroughputTracker>, http_request_throughput: Mutex<ThroughputTracker>,
// ── UDP metrics ──
active_udp_sessions: AtomicU64,
total_udp_sessions: AtomicU64,
total_datagrams_in: AtomicU64,
total_datagrams_out: AtomicU64,
// ── Lock-free pending throughput counters (hot path) ── // ── Lock-free pending throughput counters (hot path) ──
global_pending_tp_in: AtomicU64, global_pending_tp_in: AtomicU64,
global_pending_tp_out: AtomicU64, global_pending_tp_out: AtomicU64,
@@ -180,6 +191,10 @@ impl MetricsCollector {
backend_pool_hits: DashMap::new(), backend_pool_hits: DashMap::new(),
backend_pool_misses: DashMap::new(), backend_pool_misses: DashMap::new(),
backend_h2_failures: DashMap::new(), backend_h2_failures: DashMap::new(),
active_udp_sessions: AtomicU64::new(0),
total_udp_sessions: AtomicU64::new(0),
total_datagrams_in: AtomicU64::new(0),
total_datagrams_out: AtomicU64::new(0),
total_http_requests: AtomicU64::new(0), total_http_requests: AtomicU64::new(0),
pending_http_requests: AtomicU64::new(0), pending_http_requests: AtomicU64::new(0),
http_request_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)), http_request_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)),
@@ -350,6 +365,29 @@ impl MetricsCollector {
self.pending_http_requests.fetch_add(1, Ordering::Relaxed); self.pending_http_requests.fetch_add(1, Ordering::Relaxed);
} }
// ── UDP session recording methods ──
/// Record a new UDP session opened.
pub fn udp_session_opened(&self) {
self.active_udp_sessions.fetch_add(1, Ordering::Relaxed);
self.total_udp_sessions.fetch_add(1, Ordering::Relaxed);
}
/// Record a UDP session closed.
pub fn udp_session_closed(&self) {
self.active_udp_sessions.fetch_sub(1, Ordering::Relaxed);
}
/// Record a UDP datagram (inbound or outbound).
pub fn record_datagram_in(&self) {
self.total_datagrams_in.fetch_add(1, Ordering::Relaxed);
}
/// Record an outbound UDP datagram.
pub fn record_datagram_out(&self) {
self.total_datagrams_out.fetch_add(1, Ordering::Relaxed);
}
// ── Per-backend recording methods ── // ── Per-backend recording methods ──
/// Record a successful backend connection with its connect duration. /// Record a successful backend connection with its connect duration.
@@ -769,6 +807,10 @@ impl MetricsCollector {
total_http_requests: self.total_http_requests.load(Ordering::Relaxed), total_http_requests: self.total_http_requests.load(Ordering::Relaxed),
http_requests_per_sec: http_rps, http_requests_per_sec: http_rps,
http_requests_per_sec_recent: http_rps_recent, http_requests_per_sec_recent: http_rps_recent,
active_udp_sessions: self.active_udp_sessions.load(Ordering::Relaxed),
total_udp_sessions: self.total_udp_sessions.load(Ordering::Relaxed),
total_datagrams_in: self.total_datagrams_in.load(Ordering::Relaxed),
total_datagrams_out: self.total_datagrams_out.load(Ordering::Relaxed),
} }
} }
} }

View File

@@ -9,34 +9,36 @@ pub fn build_dnat_rule(
target_port: u16, target_port: u16,
options: &NfTablesOptions, options: &NfTablesOptions,
) -> Vec<String> { ) -> Vec<String> {
let protocol = match options.protocol.as_ref().unwrap_or(&NfTablesProtocol::Tcp) { let protocols: Vec<&str> = match options.protocol.as_ref().unwrap_or(&NfTablesProtocol::Tcp) {
NfTablesProtocol::Tcp => "tcp", NfTablesProtocol::Tcp => vec!["tcp"],
NfTablesProtocol::Udp => "udp", NfTablesProtocol::Udp => vec!["udp"],
NfTablesProtocol::All => "tcp", // TODO: handle "all" NfTablesProtocol::All => vec!["tcp", "udp"],
}; };
let mut rules = Vec::new(); let mut rules = Vec::new();
// DNAT rule for protocol in &protocols {
rules.push(format!( // DNAT rule
"nft add rule ip {} {} {} dport {} dnat to {}:{}",
table_name, chain_name, protocol, source_port, target_host, target_port,
));
// SNAT rule if preserving source IP is not enabled
if !options.preserve_source_ip.unwrap_or(false) {
rules.push(format!( rules.push(format!(
"nft add rule ip {} postrouting {} dport {} masquerade", "nft add rule ip {} {} {} dport {} dnat to {}:{}",
table_name, protocol, target_port, table_name, chain_name, protocol, source_port, target_host, target_port,
)); ));
}
// Rate limiting // SNAT rule if preserving source IP is not enabled
if let Some(max_rate) = &options.max_rate { if !options.preserve_source_ip.unwrap_or(false) {
rules.push(format!( rules.push(format!(
"nft add rule ip {} {} {} dport {} limit rate {} accept", "nft add rule ip {} postrouting {} dport {} masquerade",
table_name, chain_name, protocol, source_port, max_rate, table_name, protocol, target_port,
)); ));
}
// Rate limiting
if let Some(max_rate) = &options.max_rate {
rules.push(format!(
"nft add rule ip {} {} {} dport {} limit rate {} accept",
table_name, chain_name, protocol, source_port, max_rate,
));
}
} }
rules rules
@@ -120,4 +122,25 @@ mod tests {
assert_eq!(commands.len(), 1); assert_eq!(commands.len(), 1);
assert!(commands[0].contains("delete table ip rustproxy")); assert!(commands[0].contains("delete table ip rustproxy"));
} }
#[test]
fn test_protocol_all_generates_tcp_and_udp_rules() {
let mut options = make_options();
options.protocol = Some(NfTablesProtocol::All);
let rules = build_dnat_rule("rustproxy", "prerouting", 53, "10.0.0.53", 53, &options);
// Should have TCP DNAT + masquerade + UDP DNAT + masquerade = 4 rules
assert_eq!(rules.len(), 4);
assert!(rules.iter().any(|r| r.contains("tcp dport 53 dnat")));
assert!(rules.iter().any(|r| r.contains("udp dport 53 dnat")));
assert!(rules.iter().filter(|r| r.contains("masquerade")).count() == 2);
}
#[test]
fn test_protocol_udp() {
let mut options = make_options();
options.protocol = Some(NfTablesProtocol::Udp);
let rules = build_dnat_rule("rustproxy", "prerouting", 53, "10.0.0.53", 53, &options);
assert!(rules.iter().all(|r| !r.contains("tcp")));
assert!(rules.iter().any(|r| r.contains("udp dport 53 dnat")));
}
} }

View File

@@ -24,3 +24,6 @@ tokio-util = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
socket2 = { workspace = true } socket2 = { workspace = true }
quinn = { workspace = true }
rcgen = { workspace = true }
base64 = { workspace = true }

View File

@@ -1,7 +1,8 @@
//! # rustproxy-passthrough //! # rustproxy-passthrough
//! //!
//! Raw TCP/SNI passthrough engine for RustProxy. //! Raw TCP/SNI passthrough engine and UDP listener for RustProxy.
//! Handles TCP listening, TLS ClientHello SNI extraction, and bidirectional forwarding. //! Handles TCP listening, TLS ClientHello SNI extraction, bidirectional forwarding,
//! and UDP datagram session tracking with forwarding.
pub mod tcp_listener; pub mod tcp_listener;
pub mod sni_parser; pub mod sni_parser;
@@ -11,6 +12,9 @@ pub mod tls_handler;
pub mod connection_tracker; pub mod connection_tracker;
pub mod socket_relay; pub mod socket_relay;
pub mod socket_opts; pub mod socket_opts;
pub mod udp_session;
pub mod udp_listener;
pub mod quic_handler;
pub use tcp_listener::*; pub use tcp_listener::*;
pub use sni_parser::*; pub use sni_parser::*;
@@ -20,3 +24,6 @@ pub use tls_handler::*;
pub use connection_tracker::*; pub use connection_tracker::*;
pub use socket_relay::*; pub use socket_relay::*;
pub use socket_opts::*; pub use socket_opts::*;
pub use udp_session::*;
pub use udp_listener::*;
pub use quic_handler::*;

View File

@@ -0,0 +1,311 @@
//! QUIC connection handling.
//!
//! Manages QUIC endpoints (via quinn), accepts connections, and either:
//! - Forwards streams bidirectionally to TCP backends (QUIC termination)
//! - Dispatches to H3ProxyService for HTTP/3 handling (Phase 5)
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use arc_swap::ArcSwap;
use quinn::{Endpoint, ServerConfig as QuinnServerConfig};
use rustls::ServerConfig as RustlsServerConfig;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use rustproxy_config::{RouteConfig, TransportProtocol};
use rustproxy_metrics::MetricsCollector;
use rustproxy_routing::{MatchContext, RouteManager};
use crate::connection_tracker::ConnectionTracker;
/// Create a QUIC server endpoint on the given port with the provided TLS config.
///
/// The TLS config must have ALPN protocols set (e.g., `h3` for HTTP/3).
pub fn create_quic_endpoint(
port: u16,
tls_config: Arc<RustlsServerConfig>,
) -> anyhow::Result<Endpoint> {
let quic_crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls_config)
.map_err(|e| anyhow::anyhow!("Failed to create QUIC crypto config: {}", e))?;
let server_config = QuinnServerConfig::with_crypto(Arc::new(quic_crypto));
let socket = std::net::UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], port)))?;
let endpoint = Endpoint::new(
quinn::EndpointConfig::default(),
Some(server_config),
socket,
quinn::default_runtime()
.ok_or_else(|| anyhow::anyhow!("No async runtime for quinn"))?,
)?;
info!("QUIC endpoint listening on port {}", port);
Ok(endpoint)
}
/// Run the QUIC accept loop for a single endpoint.
///
/// Accepts incoming QUIC connections and spawns a task per connection.
pub async fn quic_accept_loop(
endpoint: Endpoint,
port: u16,
route_manager: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>,
conn_tracker: Arc<ConnectionTracker>,
cancel: CancellationToken,
) {
loop {
let incoming = tokio::select! {
_ = cancel.cancelled() => {
debug!("QUIC accept loop on port {} cancelled", port);
break;
}
incoming = endpoint.accept() => {
match incoming {
Some(conn) => conn,
None => {
debug!("QUIC endpoint on port {} closed", port);
break;
}
}
}
};
let remote_addr = incoming.remote_address();
let ip = remote_addr.ip();
// Per-IP rate limiting
if !conn_tracker.try_accept(&ip) {
debug!("QUIC connection rejected from {} (rate limit)", remote_addr);
// Drop `incoming` to refuse the connection
continue;
}
// Route matching (port + client IP, no domain yet — QUIC Initial is encrypted)
let rm = route_manager.load();
let ip_str = ip.to_string();
let ctx = MatchContext {
port,
domain: None,
path: None,
client_ip: Some(&ip_str),
tls_version: None,
headers: None,
is_tls: true,
protocol: Some("quic"),
transport: Some(TransportProtocol::Udp),
};
let route = match rm.find_route(&ctx) {
Some(m) => m.route.clone(),
None => {
debug!("No QUIC route matched for port {} from {}", port, remote_addr);
continue;
}
};
conn_tracker.connection_opened(&ip);
let route_id = route.name.clone().or(route.id.clone());
metrics.connection_opened(route_id.as_deref(), Some(&ip_str));
let metrics = Arc::clone(&metrics);
let conn_tracker = Arc::clone(&conn_tracker);
let cancel = cancel.child_token();
tokio::spawn(async move {
match handle_quic_connection(incoming, route, port, Arc::clone(&metrics), &cancel).await {
Ok(()) => debug!("QUIC connection from {} completed", remote_addr),
Err(e) => debug!("QUIC connection from {} error: {}", remote_addr, e),
}
// Cleanup
conn_tracker.connection_closed(&ip);
metrics.connection_closed(route_id.as_deref(), Some(&ip_str));
});
}
// Graceful shutdown: close endpoint and wait for in-flight connections
endpoint.close(quinn::VarInt::from_u32(0), b"server shutting down");
endpoint.wait_idle().await;
info!("QUIC endpoint on port {} shut down", port);
}
/// Handle a single accepted QUIC connection.
async fn handle_quic_connection(
incoming: quinn::Incoming,
route: RouteConfig,
port: u16,
metrics: Arc<MetricsCollector>,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
let connection = incoming.await?;
let remote_addr = connection.remote_address();
debug!("QUIC connection established from {}", remote_addr);
// Check if this route has HTTP/3 enabled
let enable_http3 = route.action.udp.as_ref()
.and_then(|u| u.quic.as_ref())
.and_then(|q| q.enable_http3)
.unwrap_or(false);
if enable_http3 {
// Phase 5: dispatch to H3ProxyService
// For now, log and accept streams for basic handling
debug!("HTTP/3 enabled for route {:?}, dispatching to H3 handler", route.name);
handle_h3_connection(connection, route, port, &metrics, cancel).await
} else {
// Non-HTTP3 QUIC: bidirectional stream forwarding to TCP backend
handle_quic_stream_forwarding(connection, route, port, metrics, cancel).await
}
}
/// Forward QUIC streams bidirectionally to a TCP backend.
///
/// For each accepted bidirectional QUIC stream, connects to the backend
/// via TCP and forwards data in both directions. Quinn's RecvStream/SendStream
/// implement AsyncRead/AsyncWrite, enabling reuse of existing forwarder patterns.
async fn handle_quic_stream_forwarding(
connection: quinn::Connection,
route: RouteConfig,
port: u16,
metrics: Arc<MetricsCollector>,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
let remote_addr = connection.remote_address();
let route_id = route.name.as_deref().or(route.id.as_deref());
let metrics_arc = metrics;
// Resolve backend target
let target = route.action.targets.as_ref()
.and_then(|t| t.first())
.ok_or_else(|| anyhow::anyhow!("No target for QUIC route"))?;
let backend_host = target.host.first();
let backend_port = target.port.resolve(port);
let backend_addr = format!("{}:{}", backend_host, backend_port);
loop {
let (send_stream, recv_stream) = tokio::select! {
_ = cancel.cancelled() => break,
result = connection.accept_bi() => {
match result {
Ok(streams) => streams,
Err(quinn::ConnectionError::ApplicationClosed(_)) => break,
Err(quinn::ConnectionError::LocallyClosed) => break,
Err(e) => {
debug!("QUIC stream accept error from {}: {}", remote_addr, e);
break;
}
}
}
};
let backend_addr = backend_addr.clone();
let ip_str = remote_addr.ip().to_string();
let stream_metrics = Arc::clone(&metrics_arc);
let stream_route_id = route_id.map(|s| s.to_string());
// Spawn a task for each QUIC stream → TCP bidirectional forwarding
tokio::spawn(async move {
match forward_quic_stream_to_tcp(
send_stream,
recv_stream,
&backend_addr,
).await {
Ok((bytes_in, bytes_out)) => {
stream_metrics.record_bytes(
bytes_in, bytes_out,
stream_route_id.as_deref(),
Some(&ip_str),
);
debug!("QUIC stream forwarded: {}B in, {}B out", bytes_in, bytes_out);
}
Err(e) => {
debug!("QUIC stream forwarding error: {}", e);
}
}
});
}
Ok(())
}
/// Forward a single QUIC bidirectional stream to a TCP backend connection.
async fn forward_quic_stream_to_tcp(
mut quic_send: quinn::SendStream,
mut quic_recv: quinn::RecvStream,
backend_addr: &str,
) -> anyhow::Result<(u64, u64)> {
// Connect to backend TCP
let tcp_stream = tokio::net::TcpStream::connect(backend_addr).await?;
let (mut tcp_read, mut tcp_write) = tcp_stream.into_split();
// Bidirectional copy
let client_to_backend = tokio::io::copy(&mut quic_recv, &mut tcp_write);
let backend_to_client = tokio::io::copy(&mut tcp_read, &mut quic_send);
let (c2b, b2c) = tokio::join!(client_to_backend, backend_to_client);
let bytes_in = c2b.unwrap_or(0);
let bytes_out = b2c.unwrap_or(0);
// Graceful shutdown
let _ = quic_send.finish();
let _ = tcp_write.shutdown().await;
Ok((bytes_in, bytes_out))
}
/// Placeholder for HTTP/3 connection handling (Phase 5).
///
/// Once h3_service is implemented, this will delegate to it.
async fn handle_h3_connection(
connection: quinn::Connection,
_route: RouteConfig,
_port: u16,
_metrics: &MetricsCollector,
cancel: &CancellationToken,
) -> anyhow::Result<()> {
warn!("HTTP/3 handling not yet fully implemented — accepting connection but no request processing");
// Keep the connection alive until cancelled or closed
tokio::select! {
_ = cancel.cancelled() => {}
reason = connection.closed() => {
debug!("HTTP/3 connection closed: {}", reason);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_quic_endpoint_requires_tls_config() {
// Install the ring crypto provider for tests
let _ = rustls::crypto::ring::default_provider().install_default();
// Generate a single self-signed cert and use its key pair
let self_signed = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
.unwrap();
let cert_der = self_signed.cert.der().clone();
let key_der = self_signed.key_pair.serialize_der();
let mut tls_config = RustlsServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![cert_der.into()],
rustls::pki_types::PrivateKeyDer::try_from(key_der).unwrap(),
)
.unwrap();
tls_config.alpn_protocols = vec![b"h3".to_vec()];
// Port 0 = OS assigns a free port
let result = create_quic_endpoint(0, Arc::new(tls_config));
assert!(result.is_ok(), "QUIC endpoint creation failed: {:?}", result.err());
}
}

View File

@@ -625,6 +625,7 @@ impl TcpListenerManager {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
if let Some(quick_match) = route_manager.find_route(&quick_ctx) { if let Some(quick_match) = route_manager.find_route(&quick_ctx) {
@@ -814,6 +815,7 @@ impl TcpListenerManager {
is_tls, is_tls,
// For TLS connections, protocol is unknown until after termination // For TLS connections, protocol is unknown until after termination
protocol: if is_http { Some("http") } else if !is_tls { Some("tcp") } else { None }, protocol: if is_http { Some("http") } else if !is_tls { Some("tcp") } else { None },
transport: None,
}; };
let route_match = route_manager.find_route(&ctx); let route_match = route_manager.find_route(&ctx);

View File

@@ -0,0 +1,619 @@
//! UDP listener manager.
//!
//! Binds UDP sockets on configured ports, receives datagrams, matches routes,
//! tracks sessions (flows), and forwards datagrams to backend UDP sockets.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use arc_swap::ArcSwap;
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
use tokio::sync::{Mutex, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use rustproxy_config::{RouteActionType, TransportProtocol};
use rustproxy_metrics::MetricsCollector;
use rustproxy_routing::{MatchContext, RouteManager};
use crate::connection_tracker::ConnectionTracker;
use crate::udp_session::{SessionKey, UdpSession, UdpSessionConfig, UdpSessionTable};
/// Manages UDP listeners across all configured ports.
pub struct UdpListenerManager {
/// Port → recv loop task handle
listeners: HashMap<u16, JoinHandle<()>>,
/// Hot-reloadable route table
route_manager: Arc<ArcSwap<RouteManager>>,
/// Shared metrics collector
metrics: Arc<MetricsCollector>,
/// Per-IP session/rate limiting (shared with TCP)
conn_tracker: Arc<ConnectionTracker>,
/// Shared session table across all ports
session_table: Arc<UdpSessionTable>,
/// Cancellation for graceful shutdown
cancel_token: CancellationToken,
/// Unix socket path for datagram handler relay
datagram_handler_relay: Arc<RwLock<Option<String>>>,
/// Persistent write half of the relay connection
relay_writer: Arc<Mutex<Option<tokio::net::unix::OwnedWriteHalf>>>,
/// Cancel token for the current relay reply reader task
relay_reader_cancel: Option<CancellationToken>,
}
impl Drop for UdpListenerManager {
fn drop(&mut self) {
self.cancel_token.cancel();
for (_, handle) in self.listeners.drain() {
handle.abort();
}
}
}
impl UdpListenerManager {
pub fn new(
route_manager: Arc<RouteManager>,
metrics: Arc<MetricsCollector>,
conn_tracker: Arc<ConnectionTracker>,
cancel_token: CancellationToken,
) -> Self {
Self {
listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics,
conn_tracker,
session_table: Arc::new(UdpSessionTable::new()),
cancel_token,
datagram_handler_relay: Arc::new(RwLock::new(None)),
relay_writer: Arc::new(Mutex::new(None)),
relay_reader_cancel: None,
}
}
/// Update the route manager (for hot-reload).
pub fn update_routes(&self, route_manager: Arc<RouteManager>) {
self.route_manager.store(route_manager);
}
/// Start listening on a UDP port.
///
/// If any route on this port has QUIC config (`action.udp.quic`), a quinn
/// endpoint is created instead of a raw UDP socket.
pub async fn add_port(&mut self, port: u16) -> anyhow::Result<()> {
self.add_port_with_tls(port, None).await
}
/// Start listening on a UDP port with optional TLS config for QUIC.
pub async fn add_port_with_tls(
&mut self,
port: u16,
tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
) -> anyhow::Result<()> {
if self.listeners.contains_key(&port) {
debug!("UDP port {} already listening", port);
return Ok(());
}
// Check if any route on this port uses QUIC
let rm = self.route_manager.load();
let has_quic = rm.routes_for_port(port).iter().any(|r| {
r.action.udp.as_ref()
.and_then(|u| u.quic.as_ref())
.is_some()
});
if has_quic {
if let Some(tls) = tls_config {
// Create QUIC endpoint
let endpoint = crate::quic_handler::create_quic_endpoint(port, tls)?;
let handle = tokio::spawn(crate::quic_handler::quic_accept_loop(
endpoint,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
self.cancel_token.child_token(),
));
self.listeners.insert(port, handle);
info!("QUIC endpoint started on port {}", port);
return Ok(());
} else {
warn!("QUIC routes on port {} but no TLS config provided, falling back to raw UDP", port);
}
}
// Raw UDP listener
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
let socket = UdpSocket::bind(addr).await?;
let socket = Arc::new(socket);
info!("UDP listener bound on port {}", port);
let handle = tokio::spawn(Self::recv_loop(
socket,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
Arc::clone(&self.session_table),
Arc::clone(&self.datagram_handler_relay),
Arc::clone(&self.relay_writer),
self.cancel_token.child_token(),
));
self.listeners.insert(port, handle);
// Start the session cleanup task if this is the first port
if self.listeners.len() == 1 {
self.start_cleanup_task();
}
Ok(())
}
/// Stop listening on a UDP port.
pub fn remove_port(&mut self, port: u16) {
if let Some(handle) = self.listeners.remove(&port) {
handle.abort();
info!("UDP listener removed from port {}", port);
}
}
/// Get all listening UDP ports.
pub fn listening_ports(&self) -> Vec<u16> {
let mut ports: Vec<u16> = self.listeners.keys().copied().collect();
ports.sort();
ports
}
/// Stop all listeners and clean up.
pub async fn stop(&mut self) {
self.cancel_token.cancel();
for (port, handle) in self.listeners.drain() {
handle.abort();
debug!("UDP listener stopped on port {}", port);
}
info!("All UDP listeners stopped, {} sessions remaining",
self.session_table.session_count());
}
/// Set the datagram handler relay socket path and establish connection.
pub async fn set_datagram_handler_relay(&mut self, path: String) {
// Cancel previous relay reader task if any
if let Some(old_cancel) = self.relay_reader_cancel.take() {
old_cancel.cancel();
}
// Store the path
{
let mut relay = self.datagram_handler_relay.write().await;
*relay = Some(path.clone());
}
// Connect to the Unix socket
match tokio::net::UnixStream::connect(&path).await {
Ok(stream) => {
let (read_half, write_half) = stream.into_split();
// Store write half for sending datagrams
{
let mut writer = self.relay_writer.lock().await;
*writer = Some(write_half);
}
// Spawn reply reader — reads length-prefixed JSON replies from TS
// and sends them back to clients via the listener sockets
let cancel = self.cancel_token.child_token();
self.relay_reader_cancel = Some(cancel.clone());
tokio::spawn(Self::relay_reply_reader(read_half, cancel));
info!("Datagram handler relay connected to {}", path);
}
Err(e) => {
error!("Failed to connect datagram handler relay to {}: {}", path, e);
}
}
}
/// Start periodic session cleanup task.
fn start_cleanup_task(&self) {
let session_table = Arc::clone(&self.session_table);
let metrics = Arc::clone(&self.metrics);
let conn_tracker = Arc::clone(&self.conn_tracker);
let cancel = self.cancel_token.child_token();
let route_manager = Arc::clone(&self.route_manager);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = interval.tick() => {
// Determine the timeout from routes (use the minimum configured timeout,
// or default 60s if none configured)
let rm = route_manager.load();
let timeout_ms = Self::get_min_session_timeout(&rm);
let removed = session_table.cleanup_idle(timeout_ms, &metrics, &conn_tracker);
if removed > 0 {
debug!("UDP session cleanup: removed {} idle sessions, {} remaining",
removed, session_table.session_count());
}
}
}
}
});
}
/// Get the minimum session timeout across all UDP routes.
fn get_min_session_timeout(_rm: &RouteManager) -> u64 {
// Default to 60 seconds; actual per-route timeouts checked during cleanup
60_000
}
/// Main receive loop for a UDP port.
async fn recv_loop(
socket: Arc<UdpSocket>,
port: u16,
route_manager: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>,
conn_tracker: Arc<ConnectionTracker>,
session_table: Arc<UdpSessionTable>,
_datagram_handler_relay: Arc<RwLock<Option<String>>>,
relay_writer: Arc<Mutex<Option<tokio::net::unix::OwnedWriteHalf>>>,
cancel: CancellationToken,
) {
// Use a reasonably large buffer; actual max is per-route but we need a single buffer
let mut buf = vec![0u8; 65535];
loop {
let (len, client_addr) = tokio::select! {
_ = cancel.cancelled() => {
debug!("UDP recv loop on port {} cancelled", port);
break;
}
result = socket.recv_from(&mut buf) => {
match result {
Ok(r) => r,
Err(e) => {
warn!("UDP recv error on port {}: {}", port, e);
continue;
}
}
}
};
let datagram = &buf[..len];
// Route matching
let rm = route_manager.load();
let ip_str = client_addr.ip().to_string();
let ctx = MatchContext {
port,
domain: None,
path: None,
client_ip: Some(&ip_str),
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
let route_match = match rm.find_route(&ctx) {
Some(m) => m,
None => {
debug!("No UDP route matched for port {} from {}", port, client_addr);
continue;
}
};
let route = route_match.route;
let route_id = route.name.as_deref().or(route.id.as_deref());
// Socket handler routes → relay datagram to TS via persistent Unix socket
if route.action.action_type == RouteActionType::SocketHandler {
if let Err(e) = Self::relay_datagram_via_writer(
&relay_writer,
route_id.unwrap_or("unknown"),
&client_addr,
port,
datagram,
).await {
debug!("Failed to relay UDP datagram to TS: {}", e);
}
continue;
}
// Get UDP config from route
let udp_config = UdpSessionConfig::from_route_udp(route.action.udp.as_ref());
// Check datagram size
if len as u32 > udp_config.max_datagram_size {
debug!("UDP datagram too large ({} > {}) from {}, dropping",
len, udp_config.max_datagram_size, client_addr);
continue;
}
// Session lookup or create
let session_key: SessionKey = (client_addr, port);
let session = match session_table.get(&session_key) {
Some(s) => s,
None => {
// New session — check per-IP limits
if !conn_tracker.try_accept(&client_addr.ip()) {
debug!("UDP session rejected for {} (rate limit)", client_addr);
continue;
}
if !session_table.can_create_session(
&client_addr.ip(),
udp_config.max_sessions_per_ip,
) {
debug!("UDP session rejected for {} (per-IP session limit)", client_addr);
continue;
}
// Resolve target
let target = match route_match.target.or_else(|| {
route.action.targets.as_ref().and_then(|t| t.first())
}) {
Some(t) => t,
None => {
warn!("No target for UDP route {:?}", route_id);
continue;
}
};
let backend_host = target.host.first();
let backend_port = target.port.resolve(port);
let backend_addr = format!("{}:{}", backend_host, backend_port);
// Create backend socket
let backend_socket = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
error!("Failed to bind backend UDP socket: {}", e);
continue;
}
};
if let Err(e) = backend_socket.connect(&backend_addr).await {
error!("Failed to connect backend UDP socket to {}: {}", backend_addr, e);
continue;
}
let backend_socket = Arc::new(backend_socket);
debug!("New UDP session: {} -> {} (via port {})",
client_addr, backend_addr, port);
// Spawn return-path relay task
let session_cancel = CancellationToken::new();
let return_task = tokio::spawn(Self::return_relay(
Arc::clone(&backend_socket),
Arc::clone(&socket),
client_addr,
Arc::clone(&session_table),
session_key,
Arc::clone(&metrics),
route_id.map(|s| s.to_string()),
session_cancel.child_token(),
));
let session = Arc::new(UdpSession {
backend_socket,
last_activity: std::sync::atomic::AtomicU64::new(session_table.elapsed_ms()),
created_at: std::time::Instant::now(),
route_id: route_id.map(|s| s.to_string()),
source_ip: client_addr.ip(),
client_addr,
return_task,
cancel: session_cancel,
});
if !session_table.insert(session_key, Arc::clone(&session), udp_config.max_sessions_per_ip) {
warn!("Failed to insert UDP session (race condition)");
continue;
}
// Track in metrics
conn_tracker.connection_opened(&client_addr.ip());
metrics.connection_opened(route_id, Some(&ip_str));
metrics.udp_session_opened();
session
}
};
// Forward datagram to backend
match session.backend_socket.send(datagram).await {
Ok(_) => {
session.last_activity.store(session_table.elapsed_ms(), Ordering::Relaxed);
metrics.record_bytes(len as u64, 0, route_id, Some(&ip_str));
metrics.record_datagram_in();
}
Err(e) => {
debug!("Failed to send UDP datagram to backend: {}", e);
}
}
}
}
/// Return-path relay: backend → client.
async fn return_relay(
backend_socket: Arc<UdpSocket>,
listener_socket: Arc<UdpSocket>,
client_addr: SocketAddr,
session_table: Arc<UdpSessionTable>,
session_key: SessionKey,
metrics: Arc<MetricsCollector>,
route_id: Option<String>,
cancel: CancellationToken,
) {
let mut buf = vec![0u8; 65535];
let ip_str = client_addr.ip().to_string();
loop {
let len = tokio::select! {
_ = cancel.cancelled() => break,
result = backend_socket.recv(&mut buf) => {
match result {
Ok(len) => len,
Err(e) => {
debug!("UDP backend recv error for {}: {}", client_addr, e);
break;
}
}
}
};
// Send reply back to client
match listener_socket.send_to(&buf[..len], client_addr).await {
Ok(_) => {
// Update session activity
if let Some(session) = session_table.get(&session_key) {
session.last_activity.store(session_table.elapsed_ms(), Ordering::Relaxed);
}
metrics.record_bytes(0, len as u64, route_id.as_deref(), Some(&ip_str));
metrics.record_datagram_out();
}
Err(e) => {
debug!("Failed to send UDP reply to {}: {}", client_addr, e);
break;
}
}
}
}
/// Send a datagram to TS via the persistent relay writer.
async fn relay_datagram_via_writer(
writer: &Mutex<Option<tokio::net::unix::OwnedWriteHalf>>,
route_key: &str,
client_addr: &SocketAddr,
dest_port: u16,
datagram: &[u8],
) -> anyhow::Result<()> {
use base64::Engine;
let payload_b64 = base64::engine::general_purpose::STANDARD.encode(datagram);
let msg = serde_json::json!({
"type": "datagram",
"routeKey": route_key,
"sourceIp": client_addr.ip().to_string(),
"sourcePort": client_addr.port(),
"destPort": dest_port,
"payloadBase64": payload_b64,
});
let json = serde_json::to_vec(&msg)?;
let mut guard = writer.lock().await;
let stream = guard.as_mut()
.ok_or_else(|| anyhow::anyhow!("Datagram relay not connected"))?;
// Length-prefixed frame
let len_bytes = (json.len() as u32).to_be_bytes();
stream.write_all(&len_bytes).await?;
stream.write_all(&json).await?;
stream.flush().await?;
Ok(())
}
/// Background task reading reply frames from the TS datagram handler.
/// Parses replies and sends them back to the original client via UDP.
async fn relay_reply_reader(
mut reader: tokio::net::unix::OwnedReadHalf,
cancel: CancellationToken,
) {
use base64::Engine;
let mut len_buf = [0u8; 4];
loop {
// Read length prefix
let read_result = tokio::select! {
_ = cancel.cancelled() => break,
result = reader.read_exact(&mut len_buf) => result,
};
match read_result {
Ok(_) => {}
Err(e) => {
debug!("Datagram relay reader closed: {}", e);
break;
}
}
let frame_len = u32::from_be_bytes(len_buf) as usize;
if frame_len > 10 * 1024 * 1024 {
error!("Datagram relay frame too large: {} bytes", frame_len);
break;
}
let mut frame_buf = vec![0u8; frame_len];
match reader.read_exact(&mut frame_buf).await {
Ok(_) => {}
Err(e) => {
debug!("Datagram relay reader frame error: {}", e);
break;
}
}
// Parse the reply JSON
let reply: serde_json::Value = match serde_json::from_slice(&frame_buf) {
Ok(v) => v,
Err(e) => {
debug!("Datagram relay reply parse error: {}", e);
continue;
}
};
if reply.get("type").and_then(|v| v.as_str()) != Some("reply") {
continue;
}
let source_ip = reply.get("sourceIp").and_then(|v| v.as_str()).unwrap_or("");
let source_port = reply.get("sourcePort").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
let dest_port = reply.get("destPort").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
let payload_b64 = reply.get("payloadBase64").and_then(|v| v.as_str()).unwrap_or("");
let payload = match base64::engine::general_purpose::STANDARD.decode(payload_b64) {
Ok(p) => p,
Err(e) => {
debug!("Datagram relay reply base64 decode error: {}", e);
continue;
}
};
let client_addr: SocketAddr = match format!("{}:{}", source_ip, source_port).parse() {
Ok(a) => a,
Err(e) => {
debug!("Datagram relay reply address parse error: {}", e);
continue;
}
};
// Send the reply back to the client via a temporary UDP socket bound to the dest_port
// We need the listener socket for this port. For simplicity, use a fresh socket.
let reply_socket = match UdpSocket::bind(format!("0.0.0.0:{}", dest_port)).await {
Ok(s) => s,
Err(_) => {
// Port already bound by the listener — use unbound socket
match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
debug!("Failed to create reply socket: {}", e);
continue;
}
}
}
};
if let Err(e) = reply_socket.send_to(&payload, client_addr).await {
debug!("Failed to send datagram reply to {}: {}", client_addr, e);
}
}
debug!("Datagram relay reply reader stopped");
}
}

View File

@@ -0,0 +1,324 @@
//! UDP session (flow) tracking.
//!
//! A UDP "session" is a flow identified by (client_addr, listening_port).
//! Each session maintains a backend socket bound to an ephemeral port and
//! connected to the backend target, plus a background task that relays
//! return datagrams from the backend back to the client.
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use dashmap::DashMap;
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use rustproxy_metrics::MetricsCollector;
use crate::connection_tracker::ConnectionTracker;
/// A single UDP session (flow).
pub struct UdpSession {
/// Socket bound to ephemeral port, connected to backend
pub backend_socket: Arc<UdpSocket>,
/// Milliseconds since the session table's epoch
pub last_activity: AtomicU64,
/// When the session was created
pub created_at: Instant,
/// Route ID for metrics
pub route_id: Option<String>,
/// Source IP for metrics/tracking
pub source_ip: IpAddr,
/// Client address (for return path)
pub client_addr: SocketAddr,
/// Handle for the return-path relay task
pub return_task: JoinHandle<()>,
/// Per-session cancellation
pub cancel: CancellationToken,
}
impl Drop for UdpSession {
fn drop(&mut self) {
self.cancel.cancel();
self.return_task.abort();
}
}
/// Configuration for UDP session behavior.
#[derive(Debug, Clone)]
pub struct UdpSessionConfig {
/// Idle timeout in milliseconds. Default: 60000.
pub session_timeout_ms: u64,
/// Max concurrent sessions per source IP. Default: 1000.
pub max_sessions_per_ip: u32,
/// Max accepted datagram size in bytes. Default: 65535.
pub max_datagram_size: u32,
}
impl Default for UdpSessionConfig {
fn default() -> Self {
Self {
session_timeout_ms: 60_000,
max_sessions_per_ip: 1_000,
max_datagram_size: 65_535,
}
}
}
impl UdpSessionConfig {
/// Build from route's UDP config, falling back to defaults.
pub fn from_route_udp(udp: Option<&rustproxy_config::RouteUdp>) -> Self {
match udp {
Some(u) => Self {
session_timeout_ms: u.session_timeout.unwrap_or(60_000),
max_sessions_per_ip: u.max_sessions_per_ip.unwrap_or(1_000),
max_datagram_size: u.max_datagram_size.unwrap_or(65_535),
},
None => Self::default(),
}
}
}
/// Session key: (client address, listening port).
pub type SessionKey = (SocketAddr, u16);
/// Tracks all active UDP sessions across all ports.
pub struct UdpSessionTable {
/// Active sessions keyed by (client_addr, listen_port)
sessions: DashMap<SessionKey, Arc<UdpSession>>,
/// Per-IP session counts for enforcing limits
ip_session_counts: DashMap<IpAddr, u32>,
/// Time reference for last_activity
epoch: Instant,
}
impl UdpSessionTable {
pub fn new() -> Self {
Self {
sessions: DashMap::new(),
ip_session_counts: DashMap::new(),
epoch: Instant::now(),
}
}
/// Get elapsed milliseconds since epoch (for last_activity tracking).
pub fn elapsed_ms(&self) -> u64 {
self.epoch.elapsed().as_millis() as u64
}
/// Look up an existing session.
pub fn get(&self, key: &SessionKey) -> Option<Arc<UdpSession>> {
self.sessions.get(key).map(|entry| Arc::clone(entry.value()))
}
/// Check if we can create a new session for this IP (under the per-IP limit).
pub fn can_create_session(&self, ip: &IpAddr, max_per_ip: u32) -> bool {
let count = self.ip_session_counts
.get(ip)
.map(|c| *c.value())
.unwrap_or(0);
count < max_per_ip
}
/// Insert a new session. Returns false if per-IP limit exceeded.
pub fn insert(
&self,
key: SessionKey,
session: Arc<UdpSession>,
max_per_ip: u32,
) -> bool {
let ip = session.source_ip;
// Atomically check and increment per-IP count
let mut count_entry = self.ip_session_counts.entry(ip).or_insert(0);
if *count_entry.value() >= max_per_ip {
return false;
}
*count_entry.value_mut() += 1;
drop(count_entry);
self.sessions.insert(key, session);
true
}
/// Remove a session and decrement per-IP count.
pub fn remove(&self, key: &SessionKey) -> Option<Arc<UdpSession>> {
if let Some((_, session)) = self.sessions.remove(key) {
let ip = session.source_ip;
if let Some(mut count) = self.ip_session_counts.get_mut(&ip) {
*count.value_mut() = count.value().saturating_sub(1);
if *count.value() == 0 {
drop(count);
self.ip_session_counts.remove(&ip);
}
}
Some(session)
} else {
None
}
}
/// Clean up idle sessions past the given timeout.
/// Returns the number of sessions removed.
pub fn cleanup_idle(
&self,
timeout_ms: u64,
metrics: &MetricsCollector,
conn_tracker: &ConnectionTracker,
) -> usize {
let now_ms = self.elapsed_ms();
let mut removed = 0;
// Collect keys to remove (avoid holding DashMap refs during removal)
let stale_keys: Vec<SessionKey> = self.sessions.iter()
.filter(|entry| {
let last = entry.value().last_activity.load(Ordering::Relaxed);
now_ms.saturating_sub(last) >= timeout_ms
})
.map(|entry| *entry.key())
.collect();
for key in stale_keys {
if let Some(session) = self.remove(&key) {
debug!(
"UDP session expired: {} -> port {} (idle {}ms)",
session.client_addr, key.1,
now_ms.saturating_sub(session.last_activity.load(Ordering::Relaxed))
);
conn_tracker.connection_closed(&session.source_ip);
metrics.connection_closed(
session.route_id.as_deref(),
Some(&session.source_ip.to_string()),
);
metrics.udp_session_closed();
removed += 1;
}
}
removed
}
/// Total number of active sessions.
pub fn session_count(&self) -> usize {
self.sessions.len()
}
/// Number of tracked IPs with active sessions.
pub fn tracked_ips(&self) -> usize {
self.ip_session_counts.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, SocketAddrV4};
fn make_addr(port: u16) -> SocketAddr {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), port))
}
fn make_session(client_addr: SocketAddr, cancel: CancellationToken) -> Arc<UdpSession> {
// Create a dummy backend socket for testing
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let backend_socket = rt.block_on(async {
Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap())
});
let child_cancel = cancel.child_token();
let return_task = rt.spawn(async move {
child_cancel.cancelled().await;
});
Arc::new(UdpSession {
backend_socket,
last_activity: AtomicU64::new(0),
created_at: Instant::now(),
route_id: None,
source_ip: client_addr.ip(),
client_addr,
return_task,
cancel,
})
}
#[test]
fn test_session_table_insert_and_get() {
let table = UdpSessionTable::new();
let cancel = CancellationToken::new();
let addr = make_addr(12345);
let key: SessionKey = (addr, 53);
let session = make_session(addr, cancel);
assert!(table.insert(key, session, 1000));
assert!(table.get(&key).is_some());
assert_eq!(table.session_count(), 1);
}
#[test]
fn test_session_table_per_ip_limit() {
let table = UdpSessionTable::new();
let ip = Ipv4Addr::new(10, 0, 0, 1);
// Insert 2 sessions from same IP, limit is 2
for port in [12345u16, 12346] {
let addr = SocketAddr::V4(SocketAddrV4::new(ip, port));
let cancel = CancellationToken::new();
let session = make_session(addr, cancel);
assert!(table.insert((addr, 53), session, 2));
}
// Third should be rejected
let addr3 = SocketAddr::V4(SocketAddrV4::new(ip, 12347));
let cancel3 = CancellationToken::new();
let session3 = make_session(addr3, cancel3);
assert!(!table.insert((addr3, 53), session3, 2));
assert_eq!(table.session_count(), 2);
}
#[test]
fn test_session_table_remove() {
let table = UdpSessionTable::new();
let cancel = CancellationToken::new();
let addr = make_addr(12345);
let key: SessionKey = (addr, 53);
let session = make_session(addr, cancel);
table.insert(key, session, 1000);
assert_eq!(table.session_count(), 1);
assert_eq!(table.tracked_ips(), 1);
table.remove(&key);
assert_eq!(table.session_count(), 0);
assert_eq!(table.tracked_ips(), 0);
}
#[test]
fn test_session_config_defaults() {
let config = UdpSessionConfig::default();
assert_eq!(config.session_timeout_ms, 60_000);
assert_eq!(config.max_sessions_per_ip, 1_000);
assert_eq!(config.max_datagram_size, 65_535);
}
#[test]
fn test_session_config_from_route() {
let route_udp = rustproxy_config::RouteUdp {
session_timeout: Some(10_000),
max_sessions_per_ip: Some(500),
max_datagram_size: Some(1400),
quic: None,
};
let config = UdpSessionConfig::from_route_udp(Some(&route_udp));
assert_eq!(config.session_timeout_ms, 10_000);
assert_eq!(config.max_sessions_per_ip, 500);
assert_eq!(config.max_datagram_size, 1400);
}
}

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use rustproxy_config::{RouteConfig, RouteTarget, TlsMode}; use rustproxy_config::{RouteConfig, RouteTarget, TransportProtocol, TlsMode};
use crate::matchers; use crate::matchers;
/// Context for route matching (subset of connection info). /// Context for route matching (subset of connection info).
@@ -12,8 +12,10 @@ pub struct MatchContext<'a> {
pub tls_version: Option<&'a str>, pub tls_version: Option<&'a str>,
pub headers: Option<&'a HashMap<String, String>>, pub headers: Option<&'a HashMap<String, String>>,
pub is_tls: bool, pub is_tls: bool,
/// Detected protocol: "http" or "tcp". None when unknown (e.g. pre-TLS-termination). /// Detected protocol: "http", "tcp", "udp", "quic". None when unknown.
pub protocol: Option<&'a str>, pub protocol: Option<&'a str>,
/// Transport protocol of the listener: None = TCP (backward compat), Some(Udp), Some(All).
pub transport: Option<TransportProtocol>,
} }
/// Result of a route match. /// Result of a route match.
@@ -92,6 +94,22 @@ impl RouteManager {
fn matches_route(&self, route: &RouteConfig, ctx: &MatchContext<'_>) -> bool { fn matches_route(&self, route: &RouteConfig, ctx: &MatchContext<'_>) -> bool {
let rm = &route.route_match; let rm = &route.route_match;
// Transport filtering: ensure route transport matches context transport
let route_transport = rm.transport.as_ref();
let ctx_transport = ctx.transport.as_ref();
match (route_transport, ctx_transport) {
// Route requires UDP only — reject non-UDP contexts
(Some(TransportProtocol::Udp), None) |
(Some(TransportProtocol::Udp), Some(TransportProtocol::Tcp)) => return false,
// Route requires TCP only — reject UDP contexts
(Some(TransportProtocol::Tcp), Some(TransportProtocol::Udp)) => return false,
// Route has no transport (default = TCP) — reject UDP contexts
(None, Some(TransportProtocol::Udp)) => return false,
// All other combinations match: All matches everything, same transport matches,
// None + None/Tcp matches (backward compat)
_ => {}
}
// Domain matching // Domain matching
if let Some(ref domains) = rm.domains { if let Some(ref domains) = rm.domains {
if let Some(domain) = ctx.domain { if let Some(domain) = ctx.domain {
@@ -303,6 +321,7 @@ mod tests {
id: None, id: None,
route_match: RouteMatch { route_match: RouteMatch {
ports: PortRange::Single(port), ports: PortRange::Single(port),
transport: None,
domains: domain.map(|d| DomainSpec::Single(d.to_string())), domains: domain.map(|d| DomainSpec::Single(d.to_string())),
path: None, path: None,
client_ip: None, client_ip: None,
@@ -322,6 +341,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}]), }]),
tls: None, tls: None,
@@ -332,6 +352,7 @@ mod tests {
forwarding_engine: None, forwarding_engine: None,
nftables: None, nftables: None,
send_proxy_protocol: None, send_proxy_protocol: None,
udp: None,
}, },
headers: None, headers: None,
security: None, security: None,
@@ -360,6 +381,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx); let result = manager.find_route(&ctx);
@@ -383,6 +405,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx).unwrap(); let result = manager.find_route(&ctx).unwrap();
@@ -407,6 +430,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_none()); assert!(manager.find_route(&ctx).is_none());
@@ -493,6 +517,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -513,6 +538,7 @@ mod tests {
headers: None, headers: None,
is_tls: true, is_tls: true,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_none()); assert!(manager.find_route(&ctx).is_none());
@@ -533,6 +559,7 @@ mod tests {
headers: None, headers: None,
is_tls: true, is_tls: true,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_none()); assert!(manager.find_route(&ctx).is_none());
@@ -553,6 +580,7 @@ mod tests {
headers: None, headers: None,
is_tls: true, is_tls: true,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -577,6 +605,7 @@ mod tests {
headers: None, headers: None,
is_tls: true, is_tls: true,
protocol: None, protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx); let result = manager.find_route(&ctx);
@@ -602,6 +631,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -621,6 +651,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -645,6 +676,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: Some(10), priority: Some(10),
}, },
RouteTarget { RouteTarget {
@@ -657,6 +689,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}, },
]); ]);
@@ -672,6 +705,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx).unwrap(); let result = manager.find_route(&ctx).unwrap();
assert_eq!(result.target.unwrap().host.first(), "api-backend"); assert_eq!(result.target.unwrap().host.first(), "api-backend");
@@ -686,6 +720,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None, protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx).unwrap(); let result = manager.find_route(&ctx).unwrap();
assert_eq!(result.target.unwrap().host.first(), "default-backend"); assert_eq!(result.target.unwrap().host.first(), "default-backend");
@@ -711,6 +746,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: Some("http"), protocol: Some("http"),
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
} }
@@ -729,6 +765,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: Some("tcp"), protocol: Some("tcp"),
transport: None,
}; };
assert!(manager.find_route(&ctx).is_none()); assert!(manager.find_route(&ctx).is_none());
} }
@@ -748,6 +785,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: Some("http"), protocol: Some("http"),
transport: None,
}; };
assert!(manager.find_route(&ctx_http).is_some()); assert!(manager.find_route(&ctx_http).is_some());
@@ -760,6 +798,7 @@ mod tests {
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: Some("tcp"), protocol: Some("tcp"),
transport: None,
}; };
assert!(manager.find_route(&ctx_tcp).is_some()); assert!(manager.find_route(&ctx_tcp).is_some());
} }
@@ -780,7 +819,182 @@ mod tests {
headers: None, headers: None,
is_tls: true, is_tls: true,
protocol: None, protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
} }
// ===== Transport filtering tests =====
fn make_route_with_transport(port: u16, transport: Option<TransportProtocol>) -> RouteConfig {
let mut route = make_route(port, None, 0);
route.route_match.transport = transport;
route
}
#[test]
fn test_transport_udp_route_matches_udp_context() {
let routes = vec![make_route_with_transport(53, Some(TransportProtocol::Udp))];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 53,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&ctx).is_some());
}
#[test]
fn test_transport_udp_route_rejects_tcp_context() {
let routes = vec![make_route_with_transport(53, Some(TransportProtocol::Udp))];
let manager = RouteManager::new(routes);
// TCP context (transport: None = TCP)
let ctx = MatchContext {
port: 53,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
assert!(manager.find_route(&ctx).is_none());
}
#[test]
fn test_transport_tcp_route_rejects_udp_context() {
let routes = vec![make_route_with_transport(80, Some(TransportProtocol::Tcp))];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&ctx).is_none());
}
#[test]
fn test_transport_all_matches_both() {
let routes = vec![make_route_with_transport(443, Some(TransportProtocol::All))];
let manager = RouteManager::new(routes);
// TCP context
let tcp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
assert!(manager.find_route(&tcp_ctx).is_some());
// UDP context
let udp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&udp_ctx).is_some());
}
#[test]
fn test_transport_none_default_matches_tcp_only() {
// Route with no transport field = TCP only (backward compat)
let routes = vec![make_route_with_transport(80, None)];
let manager = RouteManager::new(routes);
// TCP context should match
let tcp_ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
assert!(manager.find_route(&tcp_ctx).is_some());
// UDP context should NOT match
let udp_ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&udp_ctx).is_none());
}
#[test]
fn test_transport_mixed_routes_same_port() {
// TCP and UDP routes on the same port — each matches only its transport
let mut tcp_route = make_route_with_transport(443, Some(TransportProtocol::Tcp));
tcp_route.name = Some("tcp-route".to_string());
let mut udp_route = make_route_with_transport(443, Some(TransportProtocol::Udp));
udp_route.name = Some("udp-route".to_string());
let manager = RouteManager::new(vec![tcp_route, udp_route]);
let tcp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
let result = manager.find_route(&tcp_ctx).unwrap();
assert_eq!(result.route.name.as_deref(), Some("tcp-route"));
let udp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
let result = manager.find_route(&udp_ctx).unwrap();
assert_eq!(result.route.name.as_deref(), Some("udp-route"));
}
} }

View File

@@ -32,6 +32,7 @@ arc-swap = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
rustls = { workspace = true } rustls = { workspace = true }
rustls-pemfile = { workspace = true }
tokio-rustls = { workspace = true } tokio-rustls = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }

View File

@@ -47,7 +47,7 @@ pub use rustproxy_security;
use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec, ForwardingEngine}; use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec, ForwardingEngine};
use rustproxy_routing::RouteManager; use rustproxy_routing::RouteManager;
use rustproxy_passthrough::{TcpListenerManager, TlsCertConfig, ConnectionConfig}; use rustproxy_passthrough::{TcpListenerManager, UdpListenerManager, TlsCertConfig, ConnectionConfig};
use rustproxy_metrics::{MetricsCollector, Metrics, Statistics}; use rustproxy_metrics::{MetricsCollector, Metrics, Statistics};
use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource}; use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource};
use rustproxy_nftables::{NftManager, rule_builder}; use rustproxy_nftables::{NftManager, rule_builder};
@@ -68,6 +68,7 @@ pub struct RustProxy {
options: RustProxyOptions, options: RustProxyOptions,
route_table: ArcSwap<RouteManager>, route_table: ArcSwap<RouteManager>,
listener_manager: Option<TcpListenerManager>, listener_manager: Option<TcpListenerManager>,
udp_listener_manager: Option<UdpListenerManager>,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
cert_manager: Option<Arc<tokio::sync::Mutex<CertManager>>>, cert_manager: Option<Arc<tokio::sync::Mutex<CertManager>>>,
challenge_server: Option<challenge_server::ChallengeServer>, challenge_server: Option<challenge_server::ChallengeServer>,
@@ -114,6 +115,7 @@ impl RustProxy {
options, options,
route_table: ArcSwap::from(Arc::new(route_manager)), route_table: ArcSwap::from(Arc::new(route_manager)),
listener_manager: None, listener_manager: None,
udp_listener_manager: None,
metrics: Arc::new(MetricsCollector::with_retention(retention)), metrics: Arc::new(MetricsCollector::with_retention(retention)),
cert_manager, cert_manager,
challenge_server: None, challenge_server: None,
@@ -153,6 +155,7 @@ impl RustProxy {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
} }
]); ]);
@@ -289,17 +292,62 @@ impl RustProxy {
} }
} }
// Build QUIC TLS config before set_tls_configs consumes the map
let quic_tls_config = Self::build_quic_tls_config(&tls_configs);
if !tls_configs.is_empty() { if !tls_configs.is_empty() {
debug!("Loaded TLS certificates for {} domains", tls_configs.len()); debug!("Loaded TLS certificates for {} domains", tls_configs.len());
listener.set_tls_configs(tls_configs); listener.set_tls_configs(tls_configs);
} }
// Bind all ports // Determine which ports need TCP vs UDP based on route transport config
for port in &ports { let mut tcp_ports = std::collections::HashSet::new();
let mut udp_ports = std::collections::HashSet::new();
for route in &self.options.routes {
if !route.is_enabled() { continue; }
let transport = route.route_match.transport.as_ref();
let route_ports = route.route_match.ports.to_ports();
for port in route_ports {
match transport {
Some(rustproxy_config::TransportProtocol::Udp) => {
udp_ports.insert(port);
}
Some(rustproxy_config::TransportProtocol::All) => {
tcp_ports.insert(port);
udp_ports.insert(port);
}
Some(rustproxy_config::TransportProtocol::Tcp) | None => {
tcp_ports.insert(port);
}
}
}
}
// Bind TCP ports
for port in &tcp_ports {
listener.add_port(*port).await?; listener.add_port(*port).await?;
} }
self.listener_manager = Some(listener); self.listener_manager = Some(listener);
// Bind UDP ports (if any)
if !udp_ports.is_empty() {
let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone();
let mut udp_mgr = UdpListenerManager::new(
Arc::clone(&*self.route_table.load()),
Arc::clone(&self.metrics),
conn_tracker,
self.cancel_token.clone(),
);
for port in &udp_ports {
udp_mgr.add_port_with_tls(*port, quic_tls_config.clone()).await?;
}
info!("UDP listeners started on {} ports: {:?}",
udp_ports.len(), udp_mgr.listening_ports());
self.udp_listener_manager = Some(udp_mgr);
}
self.started = true; self.started = true;
self.started_at = Some(Instant::now()); self.started_at = Some(Instant::now());
@@ -567,6 +615,13 @@ impl RustProxy {
listener.graceful_stop().await; listener.graceful_stop().await;
} }
self.listener_manager = None; self.listener_manager = None;
// Stop UDP listeners
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
udp_mgr.stop().await;
}
self.udp_listener_manager = None;
self.started = false; self.started = false;
// Reset cancel token so proxy can be restarted // Reset cancel token so proxy can be restarted
self.cancel_token = CancellationToken::new(); self.cancel_token = CancellationToken::new();
@@ -681,6 +736,67 @@ impl RustProxy {
} }
} }
// Reconcile UDP ports
{
let mut new_udp_ports = HashSet::new();
for route in &routes {
if !route.is_enabled() { continue; }
let transport = route.route_match.transport.as_ref();
match transport {
Some(rustproxy_config::TransportProtocol::Udp) |
Some(rustproxy_config::TransportProtocol::All) => {
for port in route.route_match.ports.to_ports() {
new_udp_ports.insert(port);
}
}
_ => {}
}
}
let old_udp_ports: HashSet<u16> = self.udp_listener_manager
.as_ref()
.map(|u| u.listening_ports().into_iter().collect())
.unwrap_or_default();
if !new_udp_ports.is_empty() {
// Ensure UDP manager exists
if self.udp_listener_manager.is_none() {
if let Some(ref listener) = self.listener_manager {
let conn_tracker = listener.conn_tracker().clone();
self.udp_listener_manager = Some(UdpListenerManager::new(
Arc::clone(&new_manager),
Arc::clone(&self.metrics),
conn_tracker,
self.cancel_token.clone(),
));
}
}
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
udp_mgr.update_routes(Arc::clone(&new_manager));
// Add new UDP ports
for port in &new_udp_ports {
if !old_udp_ports.contains(port) {
udp_mgr.add_port(*port).await?;
}
}
// Remove old UDP ports
for port in &old_udp_ports {
if !new_udp_ports.contains(port) {
udp_mgr.remove_port(*port);
}
}
}
} else if self.udp_listener_manager.is_some() {
// All UDP routes removed — shut down UDP manager
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
udp_mgr.stop().await;
}
self.udp_listener_manager = None;
}
}
// Update NFTables rules: remove old, apply new // Update NFTables rules: remove old, apply new
self.update_nftables_rules(&routes).await; self.update_nftables_rules(&routes).await;
@@ -840,6 +956,65 @@ impl RustProxy {
self.socket_handler_relay.read().unwrap().clone() self.socket_handler_relay.read().unwrap().clone()
} }
/// Build a rustls ServerConfig suitable for QUIC (TLS 1.3 only, h3 ALPN).
/// Uses the first available cert from tls_configs, or returns None if no certs available.
fn build_quic_tls_config(
tls_configs: &HashMap<String, TlsCertConfig>,
) -> Option<Arc<rustls::ServerConfig>> {
// Find the first available cert (prefer wildcard, then any)
let cert_config = tls_configs.get("*")
.or_else(|| tls_configs.values().next());
let cert_config = match cert_config {
Some(c) => c,
None => return None,
};
// Parse cert chain from PEM
let mut cert_reader = std::io::BufReader::new(cert_config.cert_pem.as_bytes());
let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
rustls_pemfile::certs(&mut cert_reader)
.filter_map(|r| r.ok())
.collect();
if certs.is_empty() {
return None;
}
// Parse private key from PEM
let mut key_reader = std::io::BufReader::new(cert_config.key_pem.as_bytes());
let key = match rustls_pemfile::private_key(&mut key_reader) {
Ok(Some(key)) => key,
_ => return None,
};
let mut tls_config = match rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
{
Ok(c) => c,
Err(e) => {
warn!("Failed to build QUIC TLS config: {}", e);
return None;
}
};
// QUIC requires h3 ALPN
tls_config.alpn_protocols = vec![b"h3".to_vec()];
Some(Arc::new(tls_config))
}
/// Set the Unix domain socket path for relaying UDP datagrams to TypeScript datagramHandler callbacks.
pub async fn set_datagram_handler_relay_path(&mut self, path: Option<String>) {
info!("Datagram handler relay path set to: {:?}", path);
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
if let Some(ref p) = path {
udp_mgr.set_datagram_handler_relay(p.clone()).await;
}
}
}
/// Load a certificate for a domain and hot-swap the TLS configuration. /// Load a certificate for a domain and hot-swap the TLS configuration.
pub async fn load_certificate( pub async fn load_certificate(
&mut self, &mut self,

View File

@@ -149,6 +149,7 @@ async fn handle_request(
"getListeningPorts" => handle_get_listening_ports(&id, proxy), "getListeningPorts" => handle_get_listening_ports(&id, proxy),
"getNftablesStatus" => handle_get_nftables_status(&id, proxy).await, "getNftablesStatus" => handle_get_nftables_status(&id, proxy).await,
"setSocketHandlerRelay" => handle_set_socket_handler_relay(&id, &request.params, proxy).await, "setSocketHandlerRelay" => handle_set_socket_handler_relay(&id, &request.params, proxy).await,
"setDatagramHandlerRelay" => handle_set_datagram_handler_relay(&id, &request.params, proxy).await,
"addListeningPort" => handle_add_listening_port(&id, &request.params, proxy).await, "addListeningPort" => handle_add_listening_port(&id, &request.params, proxy).await,
"removeListeningPort" => handle_remove_listening_port(&id, &request.params, proxy).await, "removeListeningPort" => handle_remove_listening_port(&id, &request.params, proxy).await,
"loadCertificate" => handle_load_certificate(&id, &request.params, proxy).await, "loadCertificate" => handle_load_certificate(&id, &request.params, proxy).await,
@@ -391,6 +392,26 @@ async fn handle_set_socket_handler_relay(
ManagementResponse::ok(id.to_string(), serde_json::json!({})) ManagementResponse::ok(id.to_string(), serde_json::json!({}))
} }
async fn handle_set_datagram_handler_relay(
id: &str,
params: &serde_json::Value,
proxy: &mut Option<RustProxy>,
) -> ManagementResponse {
let p = match proxy.as_mut() {
Some(p) => p,
None => return ManagementResponse::err(id.to_string(), "Proxy is not running".to_string()),
};
let socket_path = params.get("socketPath")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
info!("setDatagramHandlerRelay: socket_path={:?}", socket_path);
p.set_datagram_handler_relay_path(socket_path).await;
ManagementResponse::ok(id.to_string(), serde_json::json!({}))
}
async fn handle_add_listening_port( async fn handle_add_listening_port(
id: &str, id: &str,
params: &serde_json::Value, params: &serde_json::Value,

View File

@@ -269,6 +269,7 @@ pub fn make_test_route(
id: None, id: None,
route_match: rustproxy_config::RouteMatch { route_match: rustproxy_config::RouteMatch {
ports: rustproxy_config::PortRange::Single(port), ports: rustproxy_config::PortRange::Single(port),
transport: None,
domains: domain.map(|d| rustproxy_config::DomainSpec::Single(d.to_string())), domains: domain.map(|d| rustproxy_config::DomainSpec::Single(d.to_string())),
path: None, path: None,
client_ip: None, client_ip: None,
@@ -288,6 +289,7 @@ pub fn make_test_route(
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}]), }]),
tls: None, tls: None,
@@ -298,6 +300,7 @@ pub fn make_test_route(
forwarding_engine: None, forwarding_engine: None,
nftables: None, nftables: None,
send_proxy_protocol: None, send_proxy_protocol: None,
udp: None,
}, },
headers: None, headers: None,
security: None, security: None,

View 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();

View File

@@ -174,7 +174,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
const invalidSocketResult = validateRouteAction(invalidSocketAction); const invalidSocketResult = validateRouteAction(invalidSocketAction);
expect(invalidSocketResult.valid).toBeFalse(); expect(invalidSocketResult.valid).toBeFalse();
expect(invalidSocketResult.errors.length).toBeGreaterThan(0); expect(invalidSocketResult.errors.length).toBeGreaterThan(0);
expect(invalidSocketResult.errors[0]).toInclude('Socket handler function is required'); expect(invalidSocketResult.errors[0]).toInclude('handler function is required');
}); });
tap.test('Route Validation - validateRouteConfig', async () => { tap.test('Route Validation - validateRouteConfig', async () => {

142
test/test.udp-forwarding.ts Normal file
View 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
View 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();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '25.12.0', version: '25.15.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.' 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 }>; topByErrors(limit?: number): Array<{ backend: string; errors: number }>;
}; };
// UDP metrics
udp: {
activeSessions(): number;
totalSessions(): number;
datagramsIn(): number;
datagramsOut(): number;
};
// Performance metrics // Performance metrics
percentiles: { percentiles: {
connectionDuration(): { p50: number; p95: number; p99: number }; 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'; 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 * Route match criteria for incoming requests
@@ -31,6 +37,9 @@ export interface IRouteMatch {
// Listen on these ports (required) // Listen on these ports (required)
ports: TPortRange; ports: TPortRange;
// Transport protocol: 'tcp' (default), 'udp', or 'all' (both TCP and UDP)
transport?: TTransportProtocol;
// Optional domain patterns to match (default: all domains) // Optional domain patterns to match (default: all domains)
domains?: string | string[]; domains?: string | string[];
@@ -39,7 +48,7 @@ export interface IRouteMatch {
clientIp?: string[]; // Match specific client IPs clientIp?: string[]; // Match specific client IPs
tlsVersion?: string[]; // Match specific TLS versions tlsVersion?: string[]; // Match specific TLS versions
headers?: Record<string, string | RegExp>; // Match specific HTTP headers 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 headers?: IRouteHeaders; // Override route-level headers
advanced?: IRouteAdvanced; // Override route-level advanced settings 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 for matching (higher values are checked first, default: 0)
priority?: number; priority?: number;
} }
@@ -262,7 +274,7 @@ export interface IRouteAction {
// Additional options for backend-specific settings // Additional options for backend-specific settings
options?: { options?: {
backendProtocol?: 'http1' | 'http2' | 'auto'; backendProtocol?: 'http1' | 'http2' | 'http3' | 'auto';
[key: string]: any; [key: string]: any;
}; };
@@ -274,9 +286,15 @@ export interface IRouteAction {
// Socket handler function (when type is 'socket-handler') // Socket handler function (when type is 'socket-handler')
socketHandler?: TSocketHandler; 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) // PROXY protocol support (default for all targets, can be overridden per target)
sendProxyProtocol?: boolean; 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) 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 // Configuration moved to models/interfaces.ts as ISmartProxyOptions

View File

@@ -74,6 +74,11 @@ export class RoutePreprocessor {
return true; 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 // Routes with dynamic host/port functions need TS
if (route.action.targets) { if (route.action.targets) {
for (const target of route.action.targets) { for (const target of route.action.targets) {
@@ -92,8 +97,9 @@ export class RoutePreprocessor {
if (needsTsHandling) { if (needsTsHandling) {
// Convert to socket-handler type for Rust (Rust will relay back to TS) // Convert to socket-handler type for Rust (Rust will relay back to TS)
cleanAction.type = 'socket-handler'; 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).socketHandler;
delete (cleanAction as any).datagramHandler;
} }
// Clean targets - replace functions with static values // 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 = { public percentiles = {
connectionDuration: (): { p50: number; p95: number; p99: number } => { connectionDuration: (): { p50: number; p95: number; p99: number } => {
return { p50: 0, p95: 0, p99: 0 }; return { p50: 0, p95: 0, p99: 0 };

View File

@@ -20,6 +20,7 @@ type TSmartProxyCommands = {
addListeningPort: { params: { port: number }; result: void }; addListeningPort: { params: { port: number }; result: void };
removeListeningPort: { params: { port: number }; result: void }; removeListeningPort: { params: { port: number }; result: void };
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; 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> { public async loadCertificate(domain: string, cert: string, key: string, ca?: string): Promise<void> {
await this.bridge.sendCommand('loadCertificate', { domain, cert, key, ca }); 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 { RustProxyBridge } from './rust-proxy-bridge.js';
import { RoutePreprocessor } from './route-preprocessor.js'; import { RoutePreprocessor } from './route-preprocessor.js';
import { SocketHandlerServer } from './socket-handler-server.js'; import { SocketHandlerServer } from './socket-handler-server.js';
import { DatagramHandlerServer } from './datagram-handler-server.js';
import { RustMetricsAdapter } from './rust-metrics-adapter.js'; import { RustMetricsAdapter } from './rust-metrics-adapter.js';
// Route management // Route management
@@ -36,6 +37,7 @@ export class SmartProxy extends plugins.EventEmitter {
private bridge: RustProxyBridge; private bridge: RustProxyBridge;
private preprocessor: RoutePreprocessor; private preprocessor: RoutePreprocessor;
private socketHandlerServer: SocketHandlerServer | null = null; private socketHandlerServer: SocketHandlerServer | null = null;
private datagramHandlerServer: DatagramHandlerServer | null = null;
private metricsAdapter: RustMetricsAdapter; private metricsAdapter: RustMetricsAdapter;
private routeUpdateLock: Mutex; private routeUpdateLock: Mutex;
private stopping = false; private stopping = false;
@@ -145,6 +147,16 @@ export class SmartProxy extends plugins.EventEmitter {
await this.socketHandlerServer.start(); 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) // Preprocess routes (strip JS functions, convert socket-handler routes)
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.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()); 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: '*') // Load default self-signed fallback certificate (domain: '*')
if (!this.settings.disableDefaultCert) { if (!this.settings.disableDefaultCert) {
try { try {
@@ -240,6 +257,12 @@ export class SmartProxy extends plugins.EventEmitter {
this.socketHandlerServer = null; 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' }); logger.log('info', 'SmartProxy shutdown complete.', { component: 'smart-proxy' });
} }
@@ -280,6 +303,21 @@ export class SmartProxy extends plugins.EventEmitter {
this.socketHandlerServer = null; this.socketHandlerServer = null;
} }
// Update datagram handler relay if datagram handler routes changed
const hasDatagramHandlers = newRoutes.some(
(r) => r.action.type === 'socket-handler' && r.action.datagramHandler
);
if (hasDatagramHandlers && !this.datagramHandlerServer) {
const dgPath = `/tmp/smartproxy-dgram-relay-${process.pid}.sock`;
this.datagramHandlerServer = new DatagramHandlerServer(dgPath, this.preprocessor);
await this.datagramHandlerServer.start();
await this.bridge.setDatagramHandlerRelay(this.datagramHandlerServer.getSocketPath());
} else if (!hasDatagramHandlers && this.datagramHandlerServer) {
await this.datagramHandlerServer.stop();
this.datagramHandlerServer = null;
}
// Update stored routes // Update stored routes
this.settings.routes = newRoutes; this.settings.routes = newRoutes;

View File

@@ -123,10 +123,10 @@ export class RouteValidator {
errors.push(`Invalid action type: ${route.action.type}. Must be one of: ${this.VALID_ACTION_TYPES.join(', ')}`); errors.push(`Invalid action type: ${route.action.type}. Must be one of: ${this.VALID_ACTION_TYPES.join(', ')}`);
} }
// Validate socket-handler // Validate socket-handler (TCP socketHandler or UDP datagramHandler)
if (route.action.type === 'socket-handler') { if (route.action.type === 'socket-handler') {
if (typeof route.action.socketHandler !== 'function') { if (typeof route.action.socketHandler !== 'function' && typeof route.action.datagramHandler !== 'function') {
errors.push('socket-handler action requires a socketHandler function'); errors.push('socket-handler action requires a socketHandler or datagramHandler function');
} }
} }
@@ -620,10 +620,12 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
} }
if (action.type === 'socket-handler') { if (action.type === 'socket-handler') {
if (!action.socketHandler) { if (!action.socketHandler && !action.datagramHandler) {
errors.push('Socket handler function is required for socket-handler action'); errors.push('Socket handler or datagram handler function is required for socket-handler action');
} else if (typeof action.socketHandler !== 'function') { } else if (action.socketHandler && typeof action.socketHandler !== 'function') {
errors.push('Socket handler must be a function'); errors.push('Socket handler must be a function');
} else if (action.datagramHandler && typeof action.datagramHandler !== 'function') {
errors.push('Datagram handler must be a function');
} }
} }
@@ -714,7 +716,8 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType:
route.action.targets.length > 0 && route.action.targets.length > 0 &&
route.action.targets.every(t => t.host && t.port !== undefined); route.action.targets.every(t => t.host && t.port !== undefined);
case 'socket-handler': case 'socket-handler':
return !!route.action.socketHandler && typeof route.action.socketHandler === 'function'; return (!!route.action.socketHandler && typeof route.action.socketHandler === 'function') ||
(!!route.action.datagramHandler && typeof route.action.datagramHandler === 'function');
default: default:
return false; return false;
} }