Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d0a721d5 | |||
| af970c447e | |||
| 9e1103e7a7 | |||
| 2b990527ac |
15
changelog.md
15
changelog.md
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-19 - 25.16.0 - feat(quic,http3)
|
||||||
|
add HTTP/3 proxy handling and hot-reload QUIC TLS configuration
|
||||||
|
|
||||||
|
- initialize and wire H3ProxyService into QUIC listeners so HTTP/3 requests are handled instead of being kept as placeholder connections
|
||||||
|
- add backend HTTP/3 support with protocol caching that stores Alt-Svc advertised H3 ports for auto-detection
|
||||||
|
- hot-swap TLS certificates across active QUIC endpoints and require terminating TLS for QUIC route validation
|
||||||
|
- document QUIC route setup with required TLS and ACME configuration
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-19 - 25.14.1 - fix(deps)
|
||||||
update build and runtime dependencies and align route validation test expectations
|
update build and runtime dependencies and align route validation test expectations
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "25.14.1",
|
"version": "25.16.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",
|
||||||
|
|||||||
218
readme.md
218
readme.md
@@ -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,105 @@ 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
|
||||||
|
}],
|
||||||
|
tls: {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: 'auto' // 👈 QUIC requires TLS 1.3
|
||||||
|
},
|
||||||
|
udp: {
|
||||||
|
quic: {
|
||||||
|
enableHttp3: true,
|
||||||
|
maxIdleTimeout: 30000,
|
||||||
|
maxConcurrentBidiStreams: 100,
|
||||||
|
altSvcPort: 443, // Advertise in Alt-Svc header
|
||||||
|
altSvcMaxAge: 86400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxy = new SmartProxy({
|
||||||
|
acme: { email: 'ssl@example.com' },
|
||||||
|
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 +519,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 +649,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 +660,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 +684,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 +711,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 +776,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 +820,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 +849,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 +886,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 +910,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 +919,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 +1008,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 +1047,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)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
//! Bounded, TTL-based protocol detection cache for HTTP/2 auto-detection.
|
//! Bounded, TTL-based protocol detection cache for backend protocol auto-detection.
|
||||||
//!
|
//!
|
||||||
//! Caches the ALPN-negotiated protocol (H1 or H2) per backend endpoint and requested
|
//! Caches the detected protocol (H1, H2, or H3) per backend endpoint and requested
|
||||||
//! domain (host:port + requested_host). This prevents cache oscillation when multiple
|
//! domain (host:port + requested_host). This prevents cache oscillation when multiple
|
||||||
//! frontend domains share the same backend but differ in HTTP/2 support.
|
//! frontend domains share the same backend but differ in protocol support.
|
||||||
|
//!
|
||||||
|
//! H3 detection uses the browser model: Alt-Svc headers from H1/H2 responses are
|
||||||
|
//! parsed and cached, including the advertised H3 port (which may differ from TCP).
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -29,6 +32,14 @@ pub enum DetectedProtocol {
|
|||||||
H3,
|
H3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of a protocol cache lookup.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CachedProtocol {
|
||||||
|
pub protocol: DetectedProtocol,
|
||||||
|
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
|
||||||
|
pub h3_port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Key for the protocol cache: (host, port, requested_host).
|
/// Key for the protocol cache: (host, port, requested_host).
|
||||||
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
||||||
pub struct ProtocolCacheKey {
|
pub struct ProtocolCacheKey {
|
||||||
@@ -43,6 +54,8 @@ pub struct ProtocolCacheKey {
|
|||||||
struct CachedEntry {
|
struct CachedEntry {
|
||||||
protocol: DetectedProtocol,
|
protocol: DetectedProtocol,
|
||||||
detected_at: Instant,
|
detected_at: Instant,
|
||||||
|
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
|
||||||
|
h3_port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded, TTL-based protocol detection cache.
|
/// Bounded, TTL-based protocol detection cache.
|
||||||
@@ -75,11 +88,14 @@ impl ProtocolCache {
|
|||||||
|
|
||||||
/// Look up the cached protocol for a backend endpoint.
|
/// Look up the cached protocol for a backend endpoint.
|
||||||
/// Returns `None` if not cached or expired (caller should probe via ALPN).
|
/// Returns `None` if not cached or expired (caller should probe via ALPN).
|
||||||
pub fn get(&self, key: &ProtocolCacheKey) -> Option<DetectedProtocol> {
|
pub fn get(&self, key: &ProtocolCacheKey) -> Option<CachedProtocol> {
|
||||||
let entry = self.cache.get(key)?;
|
let entry = self.cache.get(key)?;
|
||||||
if entry.detected_at.elapsed() < PROTOCOL_CACHE_TTL {
|
if entry.detected_at.elapsed() < PROTOCOL_CACHE_TTL {
|
||||||
debug!("Protocol cache hit: {:?} for {}:{} (requested: {:?})", entry.protocol, key.host, key.port, key.requested_host);
|
debug!("Protocol cache hit: {:?} for {}:{} (requested: {:?})", entry.protocol, key.host, key.port, key.requested_host);
|
||||||
Some(entry.protocol)
|
Some(CachedProtocol {
|
||||||
|
protocol: entry.protocol,
|
||||||
|
h3_port: entry.h3_port,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// Expired — remove and return None to trigger re-probe
|
// Expired — remove and return None to trigger re-probe
|
||||||
drop(entry); // release DashMap ref before remove
|
drop(entry); // release DashMap ref before remove
|
||||||
@@ -91,6 +107,16 @@ impl ProtocolCache {
|
|||||||
/// Insert a detected protocol into the cache.
|
/// Insert a detected protocol into the cache.
|
||||||
/// If the cache is at capacity, evict the oldest entry first.
|
/// If the cache is at capacity, evict the oldest entry first.
|
||||||
pub fn insert(&self, key: ProtocolCacheKey, protocol: DetectedProtocol) {
|
pub fn insert(&self, key: ProtocolCacheKey, protocol: DetectedProtocol) {
|
||||||
|
self.insert_with_h3_port(key, protocol, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an H3 detection result with the Alt-Svc advertised port.
|
||||||
|
pub fn insert_h3(&self, key: ProtocolCacheKey, h3_port: u16) {
|
||||||
|
self.insert_with_h3_port(key, DetectedProtocol::H3, Some(h3_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a protocol detection result with an optional H3 port.
|
||||||
|
fn insert_with_h3_port(&self, key: ProtocolCacheKey, protocol: DetectedProtocol, h3_port: Option<u16>) {
|
||||||
if self.cache.len() >= PROTOCOL_CACHE_MAX_ENTRIES && !self.cache.contains_key(&key) {
|
if self.cache.len() >= PROTOCOL_CACHE_MAX_ENTRIES && !self.cache.contains_key(&key) {
|
||||||
// Evict the oldest entry to stay within bounds
|
// Evict the oldest entry to stay within bounds
|
||||||
let oldest = self.cache.iter()
|
let oldest = self.cache.iter()
|
||||||
@@ -103,6 +129,7 @@ impl ProtocolCache {
|
|||||||
self.cache.insert(key, CachedEntry {
|
self.cache.insert(key, CachedEntry {
|
||||||
protocol,
|
protocol,
|
||||||
detected_at: Instant::now(),
|
detected_at: Instant::now(),
|
||||||
|
h3_port,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ const DEFAULT_WS_INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::
|
|||||||
/// Default WebSocket max lifetime (24 hours).
|
/// Default WebSocket max lifetime (24 hours).
|
||||||
const DEFAULT_WS_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(86400);
|
const DEFAULT_WS_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(86400);
|
||||||
|
|
||||||
|
/// Timeout for QUIC (H3) backend connections. Short because UDP is often firewalled.
|
||||||
|
const QUIC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Protocol decision for backend connection.
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ProtocolDecision {
|
||||||
|
H1,
|
||||||
|
H2,
|
||||||
|
H3 { port: u16 },
|
||||||
|
AlpnProbe,
|
||||||
|
}
|
||||||
|
|
||||||
/// RAII guard that decrements the active request counter on drop.
|
/// RAII guard that decrements the active request counter on drop.
|
||||||
/// Ensures the counter is correct even if the request handler panics.
|
/// Ensures the counter is correct even if the request handler panics.
|
||||||
struct ActiveRequestGuard {
|
struct ActiveRequestGuard {
|
||||||
@@ -190,6 +202,9 @@ pub struct HttpProxyService {
|
|||||||
ws_inactivity_timeout: std::time::Duration,
|
ws_inactivity_timeout: std::time::Duration,
|
||||||
/// WebSocket maximum connection lifetime.
|
/// WebSocket maximum connection lifetime.
|
||||||
ws_max_lifetime: std::time::Duration,
|
ws_max_lifetime: std::time::Duration,
|
||||||
|
/// Shared QUIC client endpoint for outbound H3 backend connections.
|
||||||
|
/// Lazily initialized on first H3 backend attempt.
|
||||||
|
quinn_client_endpoint: Arc<quinn::Endpoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpProxyService {
|
impl HttpProxyService {
|
||||||
@@ -209,6 +224,7 @@ impl HttpProxyService {
|
|||||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||||
|
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +249,7 @@ impl HttpProxyService {
|
|||||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||||
|
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,37 +662,94 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
// --- Resolve protocol decision based on backend protocol mode ---
|
// --- Resolve protocol decision based on backend protocol mode ---
|
||||||
let is_auto_detect_mode = matches!(backend_protocol_mode, rustproxy_config::BackendProtocol::Auto);
|
let is_auto_detect_mode = matches!(backend_protocol_mode, rustproxy_config::BackendProtocol::Auto);
|
||||||
let (use_h2, needs_alpn_probe) = match backend_protocol_mode {
|
let protocol_cache_key = crate::protocol_cache::ProtocolCacheKey {
|
||||||
rustproxy_config::BackendProtocol::Http1 => (false, false),
|
host: upstream.host.clone(),
|
||||||
rustproxy_config::BackendProtocol::Http2 => (true, false),
|
port: upstream.port,
|
||||||
rustproxy_config::BackendProtocol::Http3 => {
|
requested_host: host.clone(),
|
||||||
// HTTP/3 (QUIC) backend connections not yet implemented — fall back to H1
|
};
|
||||||
warn!("backendProtocol 'http3' not yet implemented, falling back to http1");
|
let protocol_decision = match backend_protocol_mode {
|
||||||
(false, false)
|
rustproxy_config::BackendProtocol::Http1 => ProtocolDecision::H1,
|
||||||
}
|
rustproxy_config::BackendProtocol::Http2 => ProtocolDecision::H2,
|
||||||
|
rustproxy_config::BackendProtocol::Http3 => ProtocolDecision::H3 { port: upstream.port },
|
||||||
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, no QUIC without TLS — default to H1
|
||||||
(false, false)
|
ProtocolDecision::H1
|
||||||
} else {
|
} else {
|
||||||
let cache_key = crate::protocol_cache::ProtocolCacheKey {
|
match self.protocol_cache.get(&protocol_cache_key) {
|
||||||
host: upstream.host.clone(),
|
Some(cached) => match cached.protocol {
|
||||||
port: upstream.port,
|
crate::protocol_cache::DetectedProtocol::H3 => {
|
||||||
requested_host: host.clone(),
|
if let Some(h3_port) = cached.h3_port {
|
||||||
};
|
ProtocolDecision::H3 { port: h3_port }
|
||||||
match self.protocol_cache.get(&cache_key) {
|
} else {
|
||||||
Some(crate::protocol_cache::DetectedProtocol::H2) => (true, false),
|
// H3 cached but no port — fall back to ALPN probe
|
||||||
Some(crate::protocol_cache::DetectedProtocol::H1) => (false, false),
|
ProtocolDecision::AlpnProbe
|
||||||
Some(crate::protocol_cache::DetectedProtocol::H3) => {
|
}
|
||||||
// H3 cached but we're on TCP — fall back to H2 probe
|
}
|
||||||
(false, true)
|
crate::protocol_cache::DetectedProtocol::H2 => ProtocolDecision::H2,
|
||||||
}
|
crate::protocol_cache::DetectedProtocol::H1 => ProtocolDecision::H1,
|
||||||
None => (false, true), // needs ALPN probe
|
},
|
||||||
|
None => ProtocolDecision::AlpnProbe,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Derive legacy flags for the existing H1/H2 connection path
|
||||||
|
let (use_h2, needs_alpn_probe) = match &protocol_decision {
|
||||||
|
ProtocolDecision::H1 => (false, false),
|
||||||
|
ProtocolDecision::H2 => (true, false),
|
||||||
|
ProtocolDecision::H3 { .. } => (false, false), // H3 path handled separately below
|
||||||
|
ProtocolDecision::AlpnProbe => (false, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- H3 path: try QUIC connection before TCP ---
|
||||||
|
if let ProtocolDecision::H3 { port: h3_port } = protocol_decision {
|
||||||
|
let h3_pool_key = crate::connection_pool::PoolKey {
|
||||||
|
host: upstream.host.clone(),
|
||||||
|
port: h3_port,
|
||||||
|
use_tls: true,
|
||||||
|
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try H3 pool checkout first
|
||||||
|
if let Some((quic_conn, _age)) = self.connection_pool.checkout_h3(&h3_pool_key) {
|
||||||
|
self.metrics.backend_pool_hit(&upstream_key);
|
||||||
|
let result = self.forward_h3(
|
||||||
|
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||||
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
|
).await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fresh QUIC connection
|
||||||
|
match self.connect_quic_backend(&upstream.host, h3_port).await {
|
||||||
|
Ok(quic_conn) => {
|
||||||
|
self.metrics.backend_pool_miss(&upstream_key);
|
||||||
|
self.metrics.backend_connection_opened(&upstream_key, std::time::Instant::now().elapsed());
|
||||||
|
let result = self.forward_h3(
|
||||||
|
quic_conn, parts, body, upstream_headers, &upstream_path,
|
||||||
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
|
).await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(backend = %upstream_key, error = %e,
|
||||||
|
"H3 backend connect failed, falling back to H2/H1");
|
||||||
|
// Invalidate H3 from cache — next request will ALPN probe for H2/H1
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
self.protocol_cache.insert(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to TCP path (ALPN probe for auto, or H1 for explicit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Connection pooling: try reusing an existing connection first ---
|
// --- Connection pooling: try reusing an existing connection first ---
|
||||||
// For ALPN probe mode, skip pool checkout (we don't know the protocol yet)
|
// For ALPN probe mode, skip pool checkout (we don't know the protocol yet)
|
||||||
if !needs_alpn_probe {
|
if !needs_alpn_probe {
|
||||||
@@ -870,6 +944,19 @@ impl HttpProxyService {
|
|||||||
};
|
};
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
self.metrics.backend_connection_closed(&upstream_key);
|
self.metrics.backend_connection_closed(&upstream_key);
|
||||||
|
|
||||||
|
// --- Alt-Svc discovery: check if backend advertises H3 ---
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
if let Ok(ref resp) = result {
|
||||||
|
if let Some(alt_svc) = resp.headers().get("alt-svc").and_then(|v| v.to_str().ok()) {
|
||||||
|
if let Some(h3_port) = parse_alt_svc_h3_port(alt_svc) {
|
||||||
|
debug!(backend = %upstream_key, h3_port, "Backend advertises H3 via Alt-Svc");
|
||||||
|
self.protocol_cache.insert_h3(protocol_cache_key, h3_port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2393,6 +2480,252 @@ impl HttpProxyService {
|
|||||||
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||||
Arc::new(config)
|
Arc::new(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a shared QUIC client endpoint for outbound H3 backend connections.
|
||||||
|
fn create_quinn_client_endpoint() -> quinn::Endpoint {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let mut tls_config = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(InsecureBackendVerifier))
|
||||||
|
.with_no_client_auth();
|
||||||
|
tls_config.alpn_protocols = vec![b"h3".to_vec()];
|
||||||
|
|
||||||
|
let quic_crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls_config)
|
||||||
|
.expect("Failed to create QUIC client crypto config");
|
||||||
|
let client_config = quinn::ClientConfig::new(Arc::new(quic_crypto));
|
||||||
|
|
||||||
|
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())
|
||||||
|
.expect("Failed to create QUIC client endpoint");
|
||||||
|
endpoint.set_default_client_config(client_config);
|
||||||
|
endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a backend via QUIC (H3).
|
||||||
|
async fn connect_quic_backend(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
) -> Result<quinn::Connection, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let addr = tokio::net::lookup_host(format!("{}:{}", host, port))
|
||||||
|
.await?
|
||||||
|
.next()
|
||||||
|
.ok_or("DNS resolution returned no addresses")?;
|
||||||
|
|
||||||
|
let server_name = host.to_string();
|
||||||
|
let connecting = self.quinn_client_endpoint.connect(addr, &server_name)?;
|
||||||
|
|
||||||
|
let connection = tokio::time::timeout(QUIC_CONNECT_TIMEOUT, connecting).await
|
||||||
|
.map_err(|_| "QUIC connect timeout (3s)")??;
|
||||||
|
|
||||||
|
debug!("QUIC backend connection established to {}:{}", host, port);
|
||||||
|
Ok(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward request to backend via HTTP/3 over QUIC.
|
||||||
|
async fn forward_h3(
|
||||||
|
&self,
|
||||||
|
quic_conn: quinn::Connection,
|
||||||
|
parts: hyper::http::request::Parts,
|
||||||
|
body: Incoming,
|
||||||
|
upstream_headers: hyper::HeaderMap,
|
||||||
|
upstream_path: &str,
|
||||||
|
route: &rustproxy_config::RouteConfig,
|
||||||
|
route_id: Option<&str>,
|
||||||
|
source_ip: &str,
|
||||||
|
pool_key: &crate::connection_pool::PoolKey,
|
||||||
|
domain: &str,
|
||||||
|
conn_activity: &ConnActivity,
|
||||||
|
backend_key: &str,
|
||||||
|
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||||
|
let h3_quinn_conn = h3_quinn::Connection::new(quic_conn.clone());
|
||||||
|
let (mut driver, mut send_request) = match h3::client::new(h3_quinn_conn).await {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
||||||
|
self.metrics.backend_handshake_error(backend_key);
|
||||||
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 handshake failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn the h3 connection driver
|
||||||
|
let driver_pool = Arc::clone(&self.connection_pool);
|
||||||
|
let driver_pool_key = pool_key.clone();
|
||||||
|
let gen_holder = Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX));
|
||||||
|
let driver_gen = Arc::clone(&gen_holder);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let close_err = std::future::poll_fn(|cx| driver.poll_close(cx)).await;
|
||||||
|
debug!("H3 connection driver closed: {:?}", close_err);
|
||||||
|
let g = driver_gen.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if g != u64::MAX {
|
||||||
|
driver_pool.remove_h3_if_generation(&driver_pool_key, g);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the H3 request
|
||||||
|
let uri = hyper::Uri::builder()
|
||||||
|
.scheme("https")
|
||||||
|
.authority(domain)
|
||||||
|
.path_and_query(upstream_path)
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| upstream_path.parse().unwrap_or_default());
|
||||||
|
|
||||||
|
let mut h3_req = hyper::Request::builder()
|
||||||
|
.method(parts.method.clone())
|
||||||
|
.uri(uri);
|
||||||
|
|
||||||
|
if let Some(headers) = h3_req.headers_mut() {
|
||||||
|
*headers = upstream_headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
let h3_req = h3_req.body(()).unwrap();
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
let mut stream = match send_request.send_request(h3_req).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
error!(backend = %backend_key, domain = %domain, error = %e, "H3 send_request failed");
|
||||||
|
self.metrics.backend_request_error(backend_key);
|
||||||
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 request failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stream request body
|
||||||
|
let rid: Option<Arc<str>> = route_id.map(Arc::from);
|
||||||
|
let sip: Arc<str> = Arc::from(source_ip);
|
||||||
|
|
||||||
|
{
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
let mut body = body;
|
||||||
|
while let Some(frame) = body.frame().await {
|
||||||
|
match frame {
|
||||||
|
Ok(frame) => {
|
||||||
|
if let Some(data) = frame.data_ref() {
|
||||||
|
self.metrics.record_bytes(data.len() as u64, 0, rid.as_deref(), Some(&sip));
|
||||||
|
if let Err(e) = stream.send_data(Bytes::copy_from_slice(data)).await {
|
||||||
|
error!(backend = %backend_key, error = %e, "H3 send_data failed");
|
||||||
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 body send failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(backend = %backend_key, error = %e, "Client body read error during H3 forward");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Signal end of body
|
||||||
|
stream.finish().await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
let h3_response = match stream.recv_response().await {
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
error!(backend = %backend_key, domain = %domain, error = %e, "H3 recv_response failed");
|
||||||
|
self.metrics.backend_request_error(backend_key);
|
||||||
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 response failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the response for the client
|
||||||
|
let status = h3_response.status();
|
||||||
|
let mut response = Response::builder().status(status);
|
||||||
|
|
||||||
|
if let Some(headers) = response.headers_mut() {
|
||||||
|
for (name, value) in h3_response.headers() {
|
||||||
|
let n = name.as_str();
|
||||||
|
// Skip hop-by-hop headers
|
||||||
|
if n == "transfer-encoding" || n == "connection" || n == "keep-alive" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.insert(name.clone(), value.clone());
|
||||||
|
}
|
||||||
|
ResponseFilter::apply_headers(route, headers, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream response body back via an adapter
|
||||||
|
let h3_body = H3ClientResponseBody { stream };
|
||||||
|
let counting_body = CountingBody::new(
|
||||||
|
h3_body,
|
||||||
|
Arc::clone(&self.metrics),
|
||||||
|
rid,
|
||||||
|
Some(sip),
|
||||||
|
Direction::Out,
|
||||||
|
).with_connection_activity(Arc::clone(&conn_activity.last_activity), conn_activity.start);
|
||||||
|
|
||||||
|
let counting_body = if let Some(ref ar) = conn_activity.active_requests {
|
||||||
|
counting_body.with_active_requests(Arc::clone(ar))
|
||||||
|
} else {
|
||||||
|
counting_body
|
||||||
|
};
|
||||||
|
|
||||||
|
let body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_body);
|
||||||
|
|
||||||
|
// Register connection in pool on success
|
||||||
|
if status != StatusCode::BAD_GATEWAY {
|
||||||
|
let g = self.connection_pool.register_h3(pool_key.clone(), quic_conn);
|
||||||
|
gen_holder.store(g, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.metrics.set_backend_protocol(backend_key, "h3");
|
||||||
|
Ok(response.body(body).unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an Alt-Svc header value to extract the H3 port.
|
||||||
|
/// Handles formats like `h3=":443"; ma=86400` and `h3=":8443", h2=":443"`.
|
||||||
|
fn parse_alt_svc_h3_port(header_value: &str) -> Option<u16> {
|
||||||
|
for directive in header_value.split(',') {
|
||||||
|
let directive = directive.trim();
|
||||||
|
// Match h3=":<port>" or h3-29=":<port>" etc.
|
||||||
|
if directive.starts_with("h3=") || directive.starts_with("h3-") {
|
||||||
|
// Find the port in ":<port>"
|
||||||
|
if let Some(start) = directive.find("\":") {
|
||||||
|
let rest = &directive[start + 2..];
|
||||||
|
if let Some(end) = rest.find('"') {
|
||||||
|
if let Ok(port) = rest[..end].parse::<u16>() {
|
||||||
|
return Some(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body adapter for H3 client responses.
|
||||||
|
/// Reads data from the h3 `RequestStream` recv side and presents it as an `http_body::Body`.
|
||||||
|
struct H3ClientResponseBody {
|
||||||
|
stream: h3::client::RequestStream<h3_quinn::BidiStream<Bytes>, Bytes>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl http_body::Body for H3ClientResponseBody {
|
||||||
|
type Data = Bytes;
|
||||||
|
type Error = hyper::Error;
|
||||||
|
|
||||||
|
fn poll_frame(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
_cx: &mut Context<'_>,
|
||||||
|
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
||||||
|
// h3's recv_data is async, so we need to poll it manually.
|
||||||
|
// Use a small future to poll the recv_data call.
|
||||||
|
use std::future::Future;
|
||||||
|
let mut fut = Box::pin(self.stream.recv_data());
|
||||||
|
match fut.as_mut().poll(_cx) {
|
||||||
|
Poll::Ready(Ok(Some(mut buf))) => {
|
||||||
|
use bytes::Buf;
|
||||||
|
let data = Bytes::copy_from_slice(buf.chunk());
|
||||||
|
buf.advance(buf.remaining());
|
||||||
|
Poll::Ready(Some(Ok(http_body::Frame::data(data))))
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(None)) => Poll::Ready(None),
|
||||||
|
Poll::Ready(Err(e)) => {
|
||||||
|
warn!("H3 response body recv error: {}", e);
|
||||||
|
Poll::Ready(None)
|
||||||
|
}
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insecure certificate verifier for backend TLS connections (fallback only).
|
/// Insecure certificate verifier for backend TLS connections (fallback only).
|
||||||
@@ -2463,6 +2796,7 @@ impl Default for HttpProxyService {
|
|||||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||||
|
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use rustproxy_config::{RouteConfig, TransportProtocol};
|
|||||||
use rustproxy_metrics::MetricsCollector;
|
use rustproxy_metrics::MetricsCollector;
|
||||||
use rustproxy_routing::{MatchContext, RouteManager};
|
use rustproxy_routing::{MatchContext, RouteManager};
|
||||||
|
|
||||||
|
use rustproxy_http::h3_service::H3ProxyService;
|
||||||
|
|
||||||
use crate::connection_tracker::ConnectionTracker;
|
use crate::connection_tracker::ConnectionTracker;
|
||||||
|
|
||||||
/// Create a QUIC server endpoint on the given port with the provided TLS config.
|
/// Create a QUIC server endpoint on the given port with the provided TLS config.
|
||||||
@@ -55,6 +57,7 @@ pub async fn quic_accept_loop(
|
|||||||
metrics: Arc<MetricsCollector>,
|
metrics: Arc<MetricsCollector>,
|
||||||
conn_tracker: Arc<ConnectionTracker>,
|
conn_tracker: Arc<ConnectionTracker>,
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
|
h3_service: Option<Arc<H3ProxyService>>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
let incoming = tokio::select! {
|
let incoming = tokio::select! {
|
||||||
@@ -113,9 +116,10 @@ pub async fn quic_accept_loop(
|
|||||||
let metrics = Arc::clone(&metrics);
|
let metrics = Arc::clone(&metrics);
|
||||||
let conn_tracker = Arc::clone(&conn_tracker);
|
let conn_tracker = Arc::clone(&conn_tracker);
|
||||||
let cancel = cancel.child_token();
|
let cancel = cancel.child_token();
|
||||||
|
let h3_svc = h3_service.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match handle_quic_connection(incoming, route, port, Arc::clone(&metrics), &cancel).await {
|
match handle_quic_connection(incoming, route, port, Arc::clone(&metrics), &cancel, h3_svc).await {
|
||||||
Ok(()) => debug!("QUIC connection from {} completed", remote_addr),
|
Ok(()) => debug!("QUIC connection from {} completed", remote_addr),
|
||||||
Err(e) => debug!("QUIC connection from {} error: {}", remote_addr, e),
|
Err(e) => debug!("QUIC connection from {} error: {}", remote_addr, e),
|
||||||
}
|
}
|
||||||
@@ -139,6 +143,7 @@ async fn handle_quic_connection(
|
|||||||
port: u16,
|
port: u16,
|
||||||
metrics: Arc<MetricsCollector>,
|
metrics: Arc<MetricsCollector>,
|
||||||
cancel: &CancellationToken,
|
cancel: &CancellationToken,
|
||||||
|
h3_service: Option<Arc<H3ProxyService>>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let connection = incoming.await?;
|
let connection = incoming.await?;
|
||||||
let remote_addr = connection.remote_address();
|
let remote_addr = connection.remote_address();
|
||||||
@@ -151,10 +156,20 @@ async fn handle_quic_connection(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if enable_http3 {
|
if enable_http3 {
|
||||||
// Phase 5: dispatch to H3ProxyService
|
if let Some(ref h3_svc) = h3_service {
|
||||||
// For now, log and accept streams for basic handling
|
debug!("HTTP/3 enabled for route {:?}, dispatching to H3ProxyService", route.name);
|
||||||
debug!("HTTP/3 enabled for route {:?}, dispatching to H3 handler", route.name);
|
h3_svc.handle_connection(connection, &route, port).await
|
||||||
handle_h3_connection(connection, route, port, &metrics, cancel).await
|
} else {
|
||||||
|
warn!("HTTP/3 enabled for route {:?} but H3ProxyService not initialized", route.name);
|
||||||
|
// Keep connection alive until cancelled
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.cancelled() => {}
|
||||||
|
reason = connection.closed() => {
|
||||||
|
debug!("HTTP/3 connection closed (no service): {}", reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-HTTP3 QUIC: bidirectional stream forwarding to TCP backend
|
// Non-HTTP3 QUIC: bidirectional stream forwarding to TCP backend
|
||||||
handle_quic_stream_forwarding(connection, route, port, metrics, cancel).await
|
handle_quic_stream_forwarding(connection, route, port, metrics, cancel).await
|
||||||
@@ -257,29 +272,6 @@ async fn forward_quic_stream_to_tcp(
|
|||||||
Ok((bytes_in, bytes_out))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ use rustproxy_config::{RouteActionType, TransportProtocol};
|
|||||||
use rustproxy_metrics::MetricsCollector;
|
use rustproxy_metrics::MetricsCollector;
|
||||||
use rustproxy_routing::{MatchContext, RouteManager};
|
use rustproxy_routing::{MatchContext, RouteManager};
|
||||||
|
|
||||||
|
use rustproxy_http::h3_service::H3ProxyService;
|
||||||
|
|
||||||
use crate::connection_tracker::ConnectionTracker;
|
use crate::connection_tracker::ConnectionTracker;
|
||||||
use crate::udp_session::{SessionKey, UdpSession, UdpSessionConfig, UdpSessionTable};
|
use crate::udp_session::{SessionKey, UdpSession, UdpSessionConfig, UdpSessionTable};
|
||||||
|
|
||||||
/// Manages UDP listeners across all configured ports.
|
/// Manages UDP listeners across all configured ports.
|
||||||
pub struct UdpListenerManager {
|
pub struct UdpListenerManager {
|
||||||
/// Port → recv loop task handle
|
/// Port → (recv loop task handle, optional QUIC endpoint for TLS updates)
|
||||||
listeners: HashMap<u16, JoinHandle<()>>,
|
listeners: HashMap<u16, (JoinHandle<()>, Option<quinn::Endpoint>)>,
|
||||||
/// Hot-reloadable route table
|
/// Hot-reloadable route table
|
||||||
route_manager: Arc<ArcSwap<RouteManager>>,
|
route_manager: Arc<ArcSwap<RouteManager>>,
|
||||||
/// Shared metrics collector
|
/// Shared metrics collector
|
||||||
@@ -44,13 +46,18 @@ pub struct UdpListenerManager {
|
|||||||
relay_writer: Arc<Mutex<Option<tokio::net::unix::OwnedWriteHalf>>>,
|
relay_writer: Arc<Mutex<Option<tokio::net::unix::OwnedWriteHalf>>>,
|
||||||
/// Cancel token for the current relay reply reader task
|
/// Cancel token for the current relay reply reader task
|
||||||
relay_reader_cancel: Option<CancellationToken>,
|
relay_reader_cancel: Option<CancellationToken>,
|
||||||
|
/// H3 proxy service for HTTP/3 request handling
|
||||||
|
h3_service: Option<Arc<H3ProxyService>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for UdpListenerManager {
|
impl Drop for UdpListenerManager {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.cancel_token.cancel();
|
self.cancel_token.cancel();
|
||||||
for (_, handle) in self.listeners.drain() {
|
for (_, (handle, endpoint)) in self.listeners.drain() {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
|
if let Some(ep) = endpoint {
|
||||||
|
ep.close(quinn::VarInt::from_u32(0), b"shutdown");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,9 +79,15 @@ impl UdpListenerManager {
|
|||||||
datagram_handler_relay: Arc::new(RwLock::new(None)),
|
datagram_handler_relay: Arc::new(RwLock::new(None)),
|
||||||
relay_writer: Arc::new(Mutex::new(None)),
|
relay_writer: Arc::new(Mutex::new(None)),
|
||||||
relay_reader_cancel: None,
|
relay_reader_cancel: None,
|
||||||
|
h3_service: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the H3 proxy service for HTTP/3 request handling.
|
||||||
|
pub fn set_h3_service(&mut self, svc: Arc<H3ProxyService>) {
|
||||||
|
self.h3_service = Some(svc);
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the route manager (for hot-reload).
|
/// Update the route manager (for hot-reload).
|
||||||
pub fn update_routes(&self, route_manager: Arc<RouteManager>) {
|
pub fn update_routes(&self, route_manager: Arc<RouteManager>) {
|
||||||
self.route_manager.store(route_manager);
|
self.route_manager.store(route_manager);
|
||||||
@@ -109,8 +122,9 @@ impl UdpListenerManager {
|
|||||||
|
|
||||||
if has_quic {
|
if has_quic {
|
||||||
if let Some(tls) = tls_config {
|
if let Some(tls) = tls_config {
|
||||||
// Create QUIC endpoint
|
// Create QUIC endpoint; clone it so we can hot-swap TLS later
|
||||||
let endpoint = crate::quic_handler::create_quic_endpoint(port, tls)?;
|
let endpoint = crate::quic_handler::create_quic_endpoint(port, tls)?;
|
||||||
|
let endpoint_for_updates = endpoint.clone(); // quinn::Endpoint is Arc-based
|
||||||
let handle = tokio::spawn(crate::quic_handler::quic_accept_loop(
|
let handle = tokio::spawn(crate::quic_handler::quic_accept_loop(
|
||||||
endpoint,
|
endpoint,
|
||||||
port,
|
port,
|
||||||
@@ -118,8 +132,9 @@ impl UdpListenerManager {
|
|||||||
Arc::clone(&self.metrics),
|
Arc::clone(&self.metrics),
|
||||||
Arc::clone(&self.conn_tracker),
|
Arc::clone(&self.conn_tracker),
|
||||||
self.cancel_token.child_token(),
|
self.cancel_token.child_token(),
|
||||||
|
self.h3_service.clone(),
|
||||||
));
|
));
|
||||||
self.listeners.insert(port, handle);
|
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
||||||
info!("QUIC endpoint started on port {}", port);
|
info!("QUIC endpoint started on port {}", port);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
} else {
|
} else {
|
||||||
@@ -145,7 +160,7 @@ impl UdpListenerManager {
|
|||||||
self.cancel_token.child_token(),
|
self.cancel_token.child_token(),
|
||||||
));
|
));
|
||||||
|
|
||||||
self.listeners.insert(port, handle);
|
self.listeners.insert(port, (handle, None));
|
||||||
|
|
||||||
// Start the session cleanup task if this is the first port
|
// Start the session cleanup task if this is the first port
|
||||||
if self.listeners.len() == 1 {
|
if self.listeners.len() == 1 {
|
||||||
@@ -157,8 +172,11 @@ impl UdpListenerManager {
|
|||||||
|
|
||||||
/// Stop listening on a UDP port.
|
/// Stop listening on a UDP port.
|
||||||
pub fn remove_port(&mut self, port: u16) {
|
pub fn remove_port(&mut self, port: u16) {
|
||||||
if let Some(handle) = self.listeners.remove(&port) {
|
if let Some((handle, endpoint)) = self.listeners.remove(&port) {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
|
if let Some(ep) = endpoint {
|
||||||
|
ep.close(quinn::VarInt::from_u32(0), b"port removed");
|
||||||
|
}
|
||||||
info!("UDP listener removed from port {}", port);
|
info!("UDP listener removed from port {}", port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,14 +191,37 @@ impl UdpListenerManager {
|
|||||||
/// Stop all listeners and clean up.
|
/// Stop all listeners and clean up.
|
||||||
pub async fn stop(&mut self) {
|
pub async fn stop(&mut self) {
|
||||||
self.cancel_token.cancel();
|
self.cancel_token.cancel();
|
||||||
for (port, handle) in self.listeners.drain() {
|
for (port, (handle, endpoint)) in self.listeners.drain() {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
|
if let Some(ep) = endpoint {
|
||||||
|
ep.close(quinn::VarInt::from_u32(0), b"shutdown");
|
||||||
|
}
|
||||||
debug!("UDP listener stopped on port {}", port);
|
debug!("UDP listener stopped on port {}", port);
|
||||||
}
|
}
|
||||||
info!("All UDP listeners stopped, {} sessions remaining",
|
info!("All UDP listeners stopped, {} sessions remaining",
|
||||||
self.session_table.session_count());
|
self.session_table.session_count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update TLS config on all active QUIC endpoints (cert refresh).
|
||||||
|
/// Only affects new incoming connections — existing connections are undisturbed.
|
||||||
|
/// Uses quinn's Endpoint::set_server_config() for zero-downtime hot-swap.
|
||||||
|
pub fn update_quic_tls(&self, tls_config: Arc<rustls::ServerConfig>) {
|
||||||
|
for (port, (_handle, endpoint)) in &self.listeners {
|
||||||
|
if let Some(ep) = endpoint {
|
||||||
|
match quinn::crypto::rustls::QuicServerConfig::try_from(Arc::clone(&tls_config)) {
|
||||||
|
Ok(quic_crypto) => {
|
||||||
|
let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_crypto));
|
||||||
|
ep.set_server_config(Some(server_config));
|
||||||
|
info!("Updated QUIC TLS config on port {}", port);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to update QUIC TLS config on port {}: {}", port, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the datagram handler relay socket path and establish connection.
|
/// Set the datagram handler relay socket path and establish connection.
|
||||||
pub async fn set_datagram_handler_relay(&mut self, path: String) {
|
pub async fn set_datagram_handler_relay(&mut self, path: String) {
|
||||||
// Cancel previous relay reader task if any
|
// Cancel previous relay reader task if any
|
||||||
|
|||||||
@@ -340,6 +340,17 @@ impl RustProxy {
|
|||||||
self.cancel_token.clone(),
|
self.cancel_token.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Construct H3ProxyService for HTTP/3 request handling
|
||||||
|
let h3_svc = rustproxy_http::h3_service::H3ProxyService::new(
|
||||||
|
Arc::new(ArcSwap::from(Arc::clone(&*self.route_table.load()))),
|
||||||
|
Arc::clone(&self.metrics),
|
||||||
|
Arc::new(rustproxy_http::connection_pool::ConnectionPool::new()),
|
||||||
|
Arc::new(rustproxy_http::protocol_cache::ProtocolCache::new()),
|
||||||
|
rustproxy_passthrough::tls_handler::shared_backend_tls_config(),
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
);
|
||||||
|
udp_mgr.set_h3_service(Arc::new(h3_svc));
|
||||||
|
|
||||||
for port in &udp_ports {
|
for port in &udp_ports {
|
||||||
udp_mgr.add_port_with_tls(*port, quic_tls_config.clone()).await?;
|
udp_mgr.add_port_with_tls(*port, quic_tls_config.clone()).await?;
|
||||||
}
|
}
|
||||||
@@ -772,13 +783,21 @@ impl RustProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build TLS config for QUIC before taking mutable borrow on udp_mgr
|
||||||
|
let quic_tls = if new_udp_ports.iter().any(|p| !old_udp_ports.contains(p)) {
|
||||||
|
let tls_configs = self.current_tls_configs().await;
|
||||||
|
Self::build_quic_tls_config(&tls_configs)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
|
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
|
||||||
udp_mgr.update_routes(Arc::clone(&new_manager));
|
udp_mgr.update_routes(Arc::clone(&new_manager));
|
||||||
|
|
||||||
// Add new UDP ports
|
// Add new UDP ports (with TLS for QUIC)
|
||||||
for port in &new_udp_ports {
|
for port in &new_udp_ports {
|
||||||
if !old_udp_ports.contains(port) {
|
if !old_udp_ports.contains(port) {
|
||||||
udp_mgr.add_port(*port).await?;
|
udp_mgr.add_port_with_tls(*port, quic_tls.clone()).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove old UDP ports
|
// Remove old UDP ports
|
||||||
@@ -1005,6 +1024,33 @@ impl RustProxy {
|
|||||||
Some(Arc::new(tls_config))
|
Some(Arc::new(tls_config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the current full TLS config map from all sources (route configs, loaded certs, cert manager).
|
||||||
|
async fn current_tls_configs(&self) -> HashMap<String, TlsCertConfig> {
|
||||||
|
let mut configs = Self::extract_tls_configs(&self.options.routes);
|
||||||
|
|
||||||
|
// Merge dynamically loaded certs (from loadCertificate IPC)
|
||||||
|
for (d, c) in &self.loaded_certs {
|
||||||
|
if !configs.contains_key(d) {
|
||||||
|
configs.insert(d.clone(), c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge certs from cert manager store
|
||||||
|
if let Some(ref cm_arc) = self.cert_manager {
|
||||||
|
let cm = cm_arc.lock().await;
|
||||||
|
for (d, b) in cm.store().iter() {
|
||||||
|
if !configs.contains_key(d) {
|
||||||
|
configs.insert(d.clone(), TlsCertConfig {
|
||||||
|
cert_pem: b.cert_pem.clone(),
|
||||||
|
key_pem: b.key_pem.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configs
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the Unix domain socket path for relaying UDP datagrams to TypeScript datagramHandler callbacks.
|
/// 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>) {
|
pub async fn set_datagram_handler_relay_path(&mut self, path: Option<String>) {
|
||||||
info!("Datagram handler relay path set to: {:?}", path);
|
info!("Datagram handler relay path set to: {:?}", path);
|
||||||
@@ -1055,37 +1101,21 @@ impl RustProxy {
|
|||||||
key_pem: key_pem.clone(),
|
key_pem: key_pem.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hot-swap TLS config on the listener
|
// Hot-swap TLS config on TCP and QUIC listeners
|
||||||
if let Some(ref mut listener) = self.listener_manager {
|
let tls_configs = self.current_tls_configs().await;
|
||||||
let mut tls_configs = Self::extract_tls_configs(&self.options.routes);
|
|
||||||
|
|
||||||
// Add the new cert
|
if let Some(ref listener) = self.listener_manager {
|
||||||
tls_configs.insert(domain.to_string(), TlsCertConfig {
|
// Build QUIC TLS config before TCP consumes the map
|
||||||
cert_pem: cert_pem.clone(),
|
let quic_tls = Self::build_quic_tls_config(&tls_configs);
|
||||||
key_pem: key_pem.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also include all existing certs from cert manager
|
|
||||||
if let Some(ref cm_arc) = self.cert_manager {
|
|
||||||
let cm = cm_arc.lock().await;
|
|
||||||
for (d, b) in cm.store().iter() {
|
|
||||||
if !tls_configs.contains_key(d) {
|
|
||||||
tls_configs.insert(d.clone(), TlsCertConfig {
|
|
||||||
cert_pem: b.cert_pem.clone(),
|
|
||||||
key_pem: b.key_pem.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge dynamically loaded certs from previous loadCertificate calls
|
|
||||||
for (d, c) in &self.loaded_certs {
|
|
||||||
if !tls_configs.contains_key(d) {
|
|
||||||
tls_configs.insert(d.clone(), c.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.set_tls_configs(tls_configs);
|
listener.set_tls_configs(tls_configs);
|
||||||
|
|
||||||
|
// Also update QUIC endpoints with the new certs
|
||||||
|
if let Some(ref udp_mgr) = self.udp_listener_manager {
|
||||||
|
if let Some(quic_config) = quic_tls {
|
||||||
|
udp_mgr.update_quic_tls(quic_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Certificate loaded and TLS config updated for {}", domain);
|
info!("Certificate loaded and TLS config updated for {}", domain);
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '25.14.1',
|
version: '25.16.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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../mod
|
|||||||
export class RouteValidator {
|
export class RouteValidator {
|
||||||
private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt'];
|
private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt'];
|
||||||
private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler'];
|
private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler'];
|
||||||
private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss'];
|
private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss', 'udp', 'quic', 'http3'];
|
||||||
private static readonly MAX_PORTS = 100;
|
private static readonly MAX_PORTS = 100;
|
||||||
private static readonly MAX_DOMAINS = 1000;
|
private static readonly MAX_DOMAINS = 1000;
|
||||||
private static readonly MAX_HEADER_SIZE = 8192;
|
private static readonly MAX_HEADER_SIZE = 8192;
|
||||||
@@ -173,6 +173,22 @@ export class RouteValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QUIC routes require TLS with termination (QUIC mandates TLS 1.3)
|
||||||
|
if (route.action.udp?.quic && route.action.type === 'forward') {
|
||||||
|
if (!route.action.tls) {
|
||||||
|
errors.push('QUIC routes require TLS configuration (action.tls) — QUIC mandates TLS 1.3');
|
||||||
|
} else if (route.action.tls.mode === 'passthrough') {
|
||||||
|
errors.push('QUIC routes cannot use TLS mode "passthrough" — use "terminate" or "terminate-and-reencrypt"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol quic/http3 requires transport udp or all
|
||||||
|
if (route.match?.protocol && ['quic', 'http3'].includes(route.match.protocol)) {
|
||||||
|
if (route.match.transport && route.match.transport !== 'udp' && route.match.transport !== 'all') {
|
||||||
|
errors.push(`Protocol "${route.match.protocol}" requires transport "udp" or "all"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate security settings
|
// Validate security settings
|
||||||
@@ -619,6 +635,15 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QUIC routes require TLS with termination
|
||||||
|
if (action.udp?.quic && action.type === 'forward') {
|
||||||
|
if (!action.tls) {
|
||||||
|
errors.push('QUIC routes require TLS configuration — QUIC mandates TLS 1.3');
|
||||||
|
} else if (action.tls.mode === 'passthrough') {
|
||||||
|
errors.push('QUIC routes cannot use TLS mode "passthrough"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === 'socket-handler') {
|
if (action.type === 'socket-handler') {
|
||||||
if (!action.socketHandler && !action.datagramHandler) {
|
if (!action.socketHandler && !action.datagramHandler) {
|
||||||
errors.push('Socket handler or datagram handler function is required for socket-handler action');
|
errors.push('Socket handler or datagram handler function is required for socket-handler action');
|
||||||
|
|||||||
Reference in New Issue
Block a user