Compare commits

...

18 Commits

Author SHA1 Message Date
jkunz 6ee7237357 v27.7.0
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-04-13 23:21:54 +00:00
jkunz b5b4c608f0 feat(smart-proxy): add typed Rust config serialization and regex header contract coverage 2026-04-13 23:21:54 +00:00
jkunz af132f40fc v27.6.0
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-04-13 18:33:28 +00:00
jkunz 781634446a feat(metrics): track per-IP domain request metrics across HTTP and TCP passthrough traffic 2026-04-13 18:33:28 +00:00
jkunz e988d935b6 v27.5.0
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-04-06 12:46:09 +00:00
jkunz 99a026627d feat(security): add domain-scoped IP allow list support across HTTP and passthrough filtering 2026-04-06 12:46:09 +00:00
jkunz 572e31587a v27.4.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-04 19:25:06 +00:00
jkunz 8587fb997c feat(rustproxy): add HTTP/3 proxy service wiring for QUIC listeners 2026-04-04 19:25:06 +00:00
jkunz 9ba101c59b v27.3.1
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-04-04 18:54:05 +00:00
jkunz 1ad3e61c15 fix(metrics): correct frontend and backend protocol connection tracking across h1, h2, h3, and websocket traffic 2026-04-04 18:54:05 +00:00
jkunz 3bfa451341 v27.3.0
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-04-04 17:59:04 +00:00
jkunz 7b3ab7378b feat(test): add end-to-end WebSocket proxy test coverage 2026-04-04 17:59:04 +00:00
jkunz 527c616cd4 v27.2.0
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-04-04 16:52:25 +00:00
jkunz b04eb0ab17 feat(metrics): add frontend and backend protocol distribution metrics 2026-04-04 16:52:25 +00:00
jkunz a55ff20391 v27.1.0
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-27 22:34:13 +00:00
jkunz 3c24bf659b feat(rustproxy-passthrough): add selective connection recycling for route, security, and certificate updates 2026-03-27 22:34:13 +00:00
jkunz 5be93c8d38 v27.0.0
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-26 20:45:41 +00:00
jkunz 788ccea81e BREAKING CHANGE(smart-proxy): remove route helper APIs and standardize route configuration on plain route objects 2026-03-26 20:45:41 +00:00
61 changed files with 4310 additions and 3066 deletions
+64
View File
@@ -1,5 +1,69 @@
# Changelog
## 2026-04-13 - 27.7.0 - feat(smart-proxy)
add typed Rust config serialization and regex header contract coverage
- serialize SmartProxy routes and top-level options into explicit Rust-safe types, including header regex literals, UDP field normalization, ACME, defaults, and proxy settings
- support JS-style regex header literals with flags in Rust header matching and add cross-contract tests for route preprocessing and config deserialization
- improve TypeScript safety for Rust bridge and metrics integration by replacing loose any-based payloads with dedicated Rust type definitions
## 2026-04-13 - 27.6.0 - feat(metrics)
track per-IP domain request metrics across HTTP and TCP passthrough traffic
- records domain request counts per frontend IP from HTTP Host headers and TCP SNI
- exposes per-IP domain maps and top IP-domain request pairs through the TypeScript metrics adapter
- bounds per-IP domain tracking and prunes stale entries to limit memory growth
- adds metrics system documentation covering architecture, collected data, and known gaps
## 2026-04-06 - 27.5.0 - feat(security)
add domain-scoped IP allow list support across HTTP and passthrough filtering
- extend route security types to accept IP allow entries scoped to specific domains
- apply domain-aware IP checks using Host headers for HTTP and SNI context for QUIC and passthrough connections
- preserve compatibility for existing plain allow list entries and add validation and tests for scoped matching
## 2026-04-04 - 27.4.0 - feat(rustproxy)
add HTTP/3 proxy service wiring for QUIC listeners
- registers H3ProxyService with the UDP listener manager so QUIC connections can serve HTTP/3
- keeps proxy IP configuration intact while enabling HTTP/3 handling during listener setup
## 2026-04-04 - 27.3.1 - fix(metrics)
correct frontend and backend protocol connection tracking across h1, h2, h3, and websocket traffic
- move frontend protocol accounting from per-request to connection lifetime tracking for HTTP/1, HTTP/2, and HTTP/3
- add backend protocol guards to connection drivers so active protocol metrics reflect live upstream connections
- prevent protocol counter underflow by using atomic saturating decrements in the metrics collector
- read backend protocol distribution directly from cached aggregate counters in the Rust metrics adapter
## 2026-04-04 - 27.3.0 - feat(test)
add end-to-end WebSocket proxy test coverage
- add comprehensive WebSocket e2e tests for upgrade handling, bidirectional messaging, header forwarding, close propagation, and large payloads
- add ws and @types/ws as development dependencies to support the new test suite
## 2026-04-04 - 27.2.0 - feat(metrics)
add frontend and backend protocol distribution metrics
- track active and total frontend protocol counts for h1, h2, h3, websocket, and other traffic
- add backend protocol counters with RAII guards to ensure metrics are decremented on all exit paths
- expose protocol distribution through the TypeScript metrics interfaces and Rust metrics adapter
## 2026-03-27 - 27.1.0 - feat(rustproxy-passthrough)
add selective connection recycling for route, security, and certificate updates
- introduce a shared connection registry to track active TCP and QUIC connections by route, source IP, and domain
- recycle only affected connections when route actions or security rules change instead of broadly invalidating traffic
- gracefully recycle existing connections when TLS certificates change for a domain
- apply route-level IP security checks to QUIC connections and share route cancellation state with UDP listeners
## 2026-03-26 - 27.0.0 - BREAKING CHANGE(smart-proxy)
remove route helper APIs and standardize route configuration on plain route objects
- Removes TypeScript route helper exports and related Rust config helpers in favor of defining routes directly with match and action properties.
- Updates documentation and tests to use plain IRouteConfig objects and SocketHandlers imports instead of helper factory functions.
- Moves socket handlers to a top-level utils export and keeps direct socket-handler route configuration as the supported pattern.
## 2026-03-26 - 26.3.0 - feat(nftables)
move NFTables forwarding management from the Rust engine to @push.rocks/smartnftables
Generated
+6 -2
View File
@@ -12,9 +12,11 @@
"npm:@push.rocks/smartserve@^2.0.3": "2.0.3",
"npm:@tsclass/tsclass@^9.5.0": "9.5.0",
"npm:@types/node@^25.5.0": "25.5.0",
"npm:@types/ws@^8.18.1": "8.18.1",
"npm:minimatch@^10.2.4": "10.2.4",
"npm:typescript@^6.0.2": "6.0.2",
"npm:why-is-node-running@^3.2.2": "3.2.2"
"npm:why-is-node-running@^3.2.2": "3.2.2",
"npm:ws@^8.20.0": "8.20.0"
},
"npm": {
"@api.global/typedrequest-interfaces@2.0.2": {
@@ -6743,9 +6745,11 @@
"npm:@push.rocks/smartserve@^2.0.3",
"npm:@tsclass/tsclass@^9.5.0",
"npm:@types/node@^25.5.0",
"npm:@types/ws@^8.18.1",
"npm:minimatch@^10.2.4",
"npm:typescript@^6.0.2",
"npm:why-is-node-running@^3.2.2"
"npm:why-is-node-running@^3.2.2",
"npm:ws@^8.20.0"
]
}
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "26.3.0",
"version": "27.7.0",
"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.",
"main": "dist_ts/index.js",
@@ -22,8 +22,10 @@
"@git.zone/tstest": "^3.6.0",
"@push.rocks/smartserve": "^2.0.3",
"@types/node": "^25.5.0",
"@types/ws": "^8.18.1",
"typescript": "^6.0.2",
"why-is-node-running": "^3.2.2"
"why-is-node-running": "^3.2.2",
"ws": "^8.20.0"
},
"dependencies": {
"@push.rocks/smartcrypto": "^2.0.4",
+7 -15
View File
@@ -45,12 +45,18 @@ importers:
'@types/node':
specifier: ^25.5.0
version: 25.5.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
typescript:
specifier: ^6.0.2
version: 6.0.2
why-is-node-running:
specifier: ^3.2.2
version: 3.2.2
ws:
specifier: ^8.20.0
version: 8.20.0
packages:
@@ -3304,18 +3310,6 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
@@ -5296,7 +5290,7 @@ snapshots:
'@push.rocks/smartenv': 6.0.0
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
ws: 8.19.0
ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
@@ -8033,8 +8027,6 @@ snapshots:
wrappy@1.0.2: {}
ws@8.19.0: {}
ws@8.20.0: {}
xml-parse-from-string@1.0.1: {}
+42 -20
View File
@@ -462,35 +462,57 @@ For TLS termination modes (`terminate` and `terminate-and-reencrypt`), SmartProx
**HTTP to HTTPS Redirect**:
```typescript
import { createHttpToHttpsRedirect } from '@push.rocks/smartproxy';
import { SocketHandlers } from '@push.rocks/smartproxy';
const redirectRoute = createHttpToHttpsRedirect(['example.com', 'www.example.com']);
const redirectRoute = {
name: 'http-to-https',
match: { ports: 80, domains: ['example.com', 'www.example.com'] },
action: {
type: 'socket-handler' as const,
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
};
```
**Complete HTTPS Server (with redirect)**:
```typescript
import { createCompleteHttpsServer } from '@push.rocks/smartproxy';
const routes = createCompleteHttpsServer(
'example.com',
{ host: 'localhost', port: 8080 },
{ certificate: 'auto' }
);
const routes = [
{
name: 'https-server',
match: { ports: 443, domains: 'example.com' },
action: {
type: 'forward' as const,
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate' as const, certificate: 'auto' as const }
}
},
{
name: 'http-redirect',
match: { ports: 80, domains: 'example.com' },
action: {
type: 'socket-handler' as const,
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
}
];
```
**Load Balancer with Health Checks**:
```typescript
import { createLoadBalancerRoute } from '@push.rocks/smartproxy';
const lbRoute = createLoadBalancerRoute(
'api.example.com',
[
{ host: 'backend1', port: 8080 },
{ host: 'backend2', port: 8080 },
{ host: 'backend3', port: 8080 }
],
{ tls: { mode: 'terminate', certificate: 'auto' } }
);
const lbRoute = {
name: 'load-balancer',
match: { ports: 443, domains: 'api.example.com' },
action: {
type: 'forward' as const,
targets: [
{ host: 'backend1', port: 8080 },
{ host: 'backend2', port: 8080 },
{ host: 'backend3', port: 8080 }
],
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
loadBalancing: { algorithm: 'round-robin' as const }
}
};
```
### Smart SNI Requirement (v22.3+)
+142 -144
View File
@@ -44,7 +44,7 @@ Whether you're building microservices, deploying edge infrastructure, proxying U
Get up and running in 30 seconds:
```typescript
import { SmartProxy, createCompleteHttpsServer } from '@push.rocks/smartproxy';
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
// Create a proxy with automatic HTTPS
const proxy = new SmartProxy({
@@ -53,13 +53,25 @@ const proxy = new SmartProxy({
useProduction: true
},
routes: [
// Complete HTTPS setup in one call! ✨
...createCompleteHttpsServer('app.example.com', {
host: 'localhost',
port: 3000
}, {
certificate: 'auto' // Automatic Let's Encrypt cert 🎩
})
// HTTPS route with automatic Let's Encrypt cert
{
name: 'https-app',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
},
// HTTP → HTTPS redirect
{
name: 'http-redirect',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
}
]
});
@@ -111,31 +123,38 @@ SmartProxy supports three TLS handling modes:
### 🌐 HTTP to HTTPS Redirect
```typescript
import { SmartProxy, createHttpToHttpsRedirect } from '@push.rocks/smartproxy';
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [
createHttpToHttpsRedirect(['example.com', '*.example.com'])
]
routes: [{
name: 'http-to-https',
match: { ports: 80, domains: ['example.com', '*.example.com'] },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
}]
});
```
### ⚖️ Load Balancer with Health Checks
```typescript
import { SmartProxy, createLoadBalancerRoute } from '@push.rocks/smartproxy';
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [
createLoadBalancerRoute(
'app.example.com',
[
routes: [{
name: 'load-balancer',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [
{ host: 'server1.internal', port: 8080 },
{ host: 'server2.internal', port: 8080 },
{ host: 'server3.internal', port: 8080 }
],
{
tls: { mode: 'terminate', certificate: 'auto' },
tls: { mode: 'terminate', certificate: 'auto' },
loadBalancing: {
algorithm: 'round-robin',
healthCheck: {
path: '/health',
@@ -145,57 +164,68 @@ const proxy = new SmartProxy({
healthyThreshold: 2
}
}
)
]
}
}]
});
```
### 🔌 WebSocket Proxy
```typescript
import { SmartProxy, createWebSocketRoute } from '@push.rocks/smartproxy';
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [
createWebSocketRoute(
'ws.example.com',
{ host: 'websocket-server', port: 8080 },
{
path: '/socket',
useTls: true,
certificate: 'auto',
routes: [{
name: 'websocket',
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
priority: 100,
action: {
type: 'forward',
targets: [{ host: 'websocket-server', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: {
enabled: true,
pingInterval: 30000,
pingTimeout: 10000
}
)
]
}
}]
});
```
### 🚦 API Gateway with Rate Limiting
```typescript
import { SmartProxy, createApiGatewayRoute, addRateLimiting } from '@push.rocks/smartproxy';
import { SmartProxy } from '@push.rocks/smartproxy';
let apiRoute = createApiGatewayRoute(
'api.example.com',
'/api',
{ host: 'api-backend', port: 8080 },
{
useTls: true,
certificate: 'auto',
addCorsHeaders: true
}
);
// Add rate limiting — 100 requests per minute per IP
apiRoute = addRateLimiting(apiRoute, {
maxRequests: 100,
window: 60,
keyBy: 'ip'
const proxy = new SmartProxy({
routes: [{
name: 'api-gateway',
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
priority: 100,
action: {
type: 'forward',
targets: [{ host: 'api-backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
headers: {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
},
security: {
rateLimit: {
enabled: true,
maxRequests: 100,
window: 60,
keyBy: 'ip'
}
}
}]
});
const proxy = new SmartProxy({ routes: [apiRoute] });
```
### 🎮 Custom Protocol Handler (TCP)
@@ -203,36 +233,40 @@ const proxy = new SmartProxy({ routes: [apiRoute] });
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:
```typescript
import { SmartProxy, createSocketHandlerRoute, SocketHandlers } from '@push.rocks/smartproxy';
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
// Use pre-built handlers
const echoRoute = createSocketHandlerRoute(
'echo.example.com',
7777,
SocketHandlers.echo
);
const proxy = new SmartProxy({
routes: [
// Use pre-built handlers
{
name: 'echo-server',
match: { ports: 7777, domains: 'echo.example.com' },
action: { type: 'socket-handler', socketHandler: SocketHandlers.echo }
},
// Or create your own custom protocol
{
name: 'custom-protocol',
match: { ports: 9999, domains: 'custom.example.com' },
action: {
type: 'socket-handler',
socketHandler: async (socket) => {
console.log(`New connection on custom protocol`);
socket.write('Welcome to my custom protocol!\n');
// Or create your own custom protocol
const customRoute = createSocketHandlerRoute(
'custom.example.com',
9999,
async (socket) => {
console.log(`New connection on custom protocol`);
socket.write('Welcome to my custom protocol!\n');
socket.on('data', (data) => {
const command = data.toString().trim();
switch (command) {
case 'PING': socket.write('PONG\n'); break;
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
case 'QUIT': socket.end('Goodbye!\n'); break;
default: socket.write(`Unknown: ${command}\n`);
socket.on('data', (data) => {
const command = data.toString().trim();
switch (command) {
case 'PING': socket.write('PONG\n'); break;
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
case 'QUIT': socket.end('Goodbye!\n'); break;
default: socket.write(`Unknown: ${command}\n`);
}
});
}
}
});
}
);
const proxy = new SmartProxy({ routes: [echoRoute, customRoute] });
}
]
});
```
**Pre-built Socket Handlers:**
@@ -387,20 +421,23 @@ const dualStackRoute: IRouteConfig = {
For ultra-low latency on Linux, use kernel-level forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables) (requires root):
```typescript
import { SmartProxy, createNfTablesTerminateRoute } from '@push.rocks/smartproxy';
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [
createNfTablesTerminateRoute(
'fast.example.com',
{ host: 'backend', port: 8080 },
{
ports: 443,
certificate: 'auto',
routes: [{
name: 'nftables-fast',
match: { ports: 443, domains: 'fast.example.com' },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
nftables: {
protocol: 'tcp',
preserveSourceIP: true // Backend sees real client IP
}
)
]
}
}]
});
```
@@ -409,15 +446,18 @@ const proxy = new SmartProxy({
Forward encrypted traffic to backends without terminating TLS — the proxy routes based on the SNI hostname alone:
```typescript
import { SmartProxy, createHttpsPassthroughRoute } from '@push.rocks/smartproxy';
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [
createHttpsPassthroughRoute('secure.example.com', {
host: 'backend-that-handles-tls',
port: 8443
})
]
routes: [{
name: 'sni-passthrough',
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
targets: [{ host: 'backend-that-handles-tls', port: 8443 }],
tls: { mode: 'passthrough' }
}
}]
});
```
@@ -524,15 +564,7 @@ Comprehensive per-route security options:
}
```
**Security modifier helpers** let you add security to any existing route:
```typescript
import { addRateLimiting, addBasicAuth, addJwtAuth } from '@push.rocks/smartproxy';
let route = createHttpsTerminateRoute('api.example.com', { host: 'backend', port: 8080 });
route = addRateLimiting(route, { maxRequests: 100, window: 60, keyBy: 'ip' });
route = addBasicAuth(route, { users: [{ username: 'admin', password: 'secret' }] });
```
Security options are configured directly on each route's `security` property — no separate helpers needed.
### 📊 Runtime Management
@@ -712,7 +744,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
```
- **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 handler callbacks
- **TypeScript** provides the npm API, configuration types, validation, and handler callbacks
- **NFTables** managed by [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables) — kernel-level DNAT/SNAT forwarding, firewall rules, and rate limiting via the `nft` CLI
- **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary
- **Socket/Datagram Relay** — Unix domain socket servers for routes requiring TypeScript-side handling (socket handlers, datagram handlers, dynamic host/port functions)
@@ -858,47 +890,13 @@ interface IRouteQuic {
}
```
## 🛠️ Helper Functions Reference
All helpers are fully typed and return `IRouteConfig` or `IRouteConfig[]`:
## 🛠️ Exports Reference
```typescript
import {
// HTTP/HTTPS
createHttpRoute, // Plain HTTP route
createHttpsTerminateRoute, // HTTPS with TLS termination
createHttpsPassthroughRoute, // SNI passthrough (no termination)
createHttpToHttpsRedirect, // HTTP → HTTPS redirect
createCompleteHttpsServer, // HTTPS + redirect combo (returns IRouteConfig[])
// Load Balancing
createLoadBalancerRoute, // Multi-backend with health checks
createSmartLoadBalancer, // Dynamic domain-based backend selection
// API & WebSocket
createApiRoute, // API route with path matching
createApiGatewayRoute, // API gateway with CORS
createWebSocketRoute, // WebSocket-enabled route
// Custom Protocols
createSocketHandlerRoute, // Custom TCP socket handler
SocketHandlers, // Pre-built handlers (echo, proxy, block, etc.)
// NFTables (Linux, requires root)
createNfTablesRoute, // Kernel-level packet forwarding
createNfTablesTerminateRoute, // NFTables + TLS termination
createCompleteNfTablesHttpsServer, // NFTables HTTPS + redirect combo
// Dynamic Routing
createPortMappingRoute, // Port mapping with context
createOffsetPortMappingRoute, // Simple port offset
createDynamicRoute, // Dynamic host/port via functions
createPortOffset, // Port offset factory
// Security Modifiers
addRateLimiting, // Add rate limiting to any route
addBasicAuth, // Add basic auth to any route
addJwtAuth, // Add JWT auth to any route
// Core
SmartProxy, // Main proxy class
SocketHandlers, // Pre-built socket handlers (echo, proxy, block, httpRedirect, httpServer, etc.)
// Route Utilities
mergeRouteConfigs, // Deep-merge two route configs
@@ -910,7 +908,7 @@ import {
} 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.
All routes are configured as plain `IRouteConfig` objects with `match` and `action` properties — see the examples throughout this document.
## 📖 API Documentation
+484
View File
@@ -0,0 +1,484 @@
# SmartProxy Metrics System
## Architecture
Two-tier design separating the data plane from the observation plane:
**Hot path (per-chunk, lock-free):** All recording in the proxy data plane touches only `AtomicU64` counters. No `Mutex` is ever acquired on the forwarding path. `CountingBody` batches flushes every 64KB to reduce DashMap shard contention.
**Cold path (1Hz sampling):** A background tokio task drains pending atomics into `ThroughputTracker` circular buffers (Mutex-guarded), producing per-second throughput history. Same task prunes orphaned entries and cleans up rate limiter state.
**Read path (on-demand):** `snapshot()` reads all atomics and locks ThroughputTrackers to build a serializable `Metrics` struct. TypeScript polls this at 1s intervals via IPC.
```
Data Plane (lock-free) Background (1Hz) Read Path
───────────────────── ────────────────── ─────────
record_bytes() ──> AtomicU64 ──┐
record_http_request() ──> AtomicU64 ──┤
connection_opened/closed() ──> AtomicU64 ──┤ sample_all() snapshot()
backend_*() ──> DashMap<AtomicU64> ──┤────> drain atomics ──────> Metrics struct
protocol_*() ──> AtomicU64 ──┤ feed ThroughputTrackers ──> JSON
datagram_*() ──> AtomicU64 ──┘ prune orphans ──> IPC stdout
──> TS cache
──> IMetrics API
```
### Key Types
| Type | Crate | Purpose |
|---|---|---|
| `MetricsCollector` | `rustproxy-metrics` | Central store. All DashMaps, atomics, and throughput trackers |
| `ThroughputTracker` | `rustproxy-metrics` | Circular buffer of 1Hz samples. Default 3600 capacity (1 hour) |
| `ForwardMetricsCtx` | `rustproxy-passthrough` | Carries `Arc<MetricsCollector>` + route_id + source_ip through TCP forwarding |
| `CountingBody` | `rustproxy-http` | Wraps HTTP bodies, batches byte recording per 64KB, flushes on drop |
| `ProtocolGuard` | `rustproxy-http` | RAII guard for frontend/backend protocol active/total counters |
| `ConnectionGuard` | `rustproxy-passthrough` | RAII guard calling `connection_closed()` on drop |
| `RustMetricsAdapter` | TypeScript | Polls Rust via IPC, implements `IMetrics` interface over cached JSON |
---
## What's Collected
### Global Counters
| Metric | Type | Updated by |
|---|---|---|
| Active connections | AtomicU64 | `connection_opened/closed` |
| Total connections (lifetime) | AtomicU64 | `connection_opened` |
| Total bytes in | AtomicU64 | `record_bytes` |
| Total bytes out | AtomicU64 | `record_bytes` |
| Total HTTP requests | AtomicU64 | `record_http_request` |
| Active UDP sessions | AtomicU64 | `udp_session_opened/closed` |
| Total UDP sessions | AtomicU64 | `udp_session_opened` |
| Total datagrams in | AtomicU64 | `record_datagram_in` |
| Total datagrams out | AtomicU64 | `record_datagram_out` |
### Per-Route Metrics (keyed by route ID string)
| Metric | Storage |
|---|---|
| Active connections | `DashMap<String, AtomicU64>` |
| Total connections | `DashMap<String, AtomicU64>` |
| Bytes in / out | `DashMap<String, AtomicU64>` |
| Pending throughput (in, out) | `DashMap<String, (AtomicU64, AtomicU64)>` |
| Throughput history | `DashMap<String, Mutex<ThroughputTracker>>` |
Entries are pruned via `retain_routes()` when routes are removed.
### Per-IP Metrics (keyed by IP string)
| Metric | Storage |
|---|---|
| Active connections | `DashMap<String, AtomicU64>` |
| Total connections | `DashMap<String, AtomicU64>` |
| Bytes in / out | `DashMap<String, AtomicU64>` |
| Pending throughput (in, out) | `DashMap<String, (AtomicU64, AtomicU64)>` |
| Throughput history | `DashMap<String, Mutex<ThroughputTracker>>` |
| Domain requests | `DashMap<String, DashMap<String, AtomicU64>>` (IP → domain → count) |
All seven maps for an IP are evicted when its active connection count drops to 0. Safety-net pruning in `sample_all()` catches entries orphaned by races. Snapshots cap at 100 IPs, sorted by active connections descending.
**Domain request tracking:** Records which domains each frontend IP has requested. Populated from HTTP Host headers (for HTTP/1.1, HTTP/2, HTTP/3 requests) and from SNI (for TLS passthrough connections). Capped at 256 domains per IP (`MAX_DOMAINS_PER_IP`) to prevent subdomain-spray abuse. Inner DashMap uses 2 shards to minimise base memory per IP (~200 bytes). Common case (IP + domain both known) is two DashMap reads + one atomic increment with zero allocation.
### Per-Backend Metrics (keyed by "host:port")
| Metric | Storage |
|---|---|
| Active connections | `DashMap<String, AtomicU64>` |
| Total connections | `DashMap<String, AtomicU64>` |
| Detected protocol (h1/h2/h3) | `DashMap<String, String>` |
| Connect errors | `DashMap<String, AtomicU64>` |
| Handshake errors | `DashMap<String, AtomicU64>` |
| Request errors | `DashMap<String, AtomicU64>` |
| Total connect time (microseconds) | `DashMap<String, AtomicU64>` |
| Connect count | `DashMap<String, AtomicU64>` |
| Pool hits | `DashMap<String, AtomicU64>` |
| Pool misses | `DashMap<String, AtomicU64>` |
| H2 failures (fallback to H1) | `DashMap<String, AtomicU64>` |
All per-backend maps are evicted when active count reaches 0. Pruned via `retain_backends()`.
### Frontend Protocol Distribution
Tracked via `ProtocolGuard` RAII guards and `FrontendProtocolTracker`. Five protocol categories, each with active + total counters (AtomicU64):
| Protocol | Where detected |
|---|---|
| h1 | `FrontendProtocolTracker` on first HTTP/1.x request |
| h2 | `FrontendProtocolTracker` on first HTTP/2 request |
| h3 | `ProtocolGuard::frontend("h3")` in H3ProxyService |
| ws | `ProtocolGuard::frontend("ws")` on WebSocket upgrade |
| other | Fallback (TCP passthrough without HTTP) |
Uses `fetch_update` for saturating decrements to prevent underflow races.
### Backend Protocol Distribution
Same five categories (h1/h2/h3/ws/other), tracked via `ProtocolGuard::backend()` at connection establishment. Backend h2 failures (fallback to h1) are separately counted.
### Throughput History
`ThroughputTracker` is a circular buffer storing `ThroughputSample { timestamp_ms, bytes_in, bytes_out }` at 1Hz.
- Global tracker: 1 instance, default 3600 capacity
- Per-route trackers: 1 per active route
- Per-IP trackers: 1 per connected IP (evicted with the IP)
- HTTP request tracker: reuses ThroughputTracker with bytes_in = request count, bytes_out = 0
Query methods:
- `instant()` — last 1 second average
- `recent()` — last 10 seconds average
- `throughput(N)` — last N seconds average
- `history(N)` — last N raw samples in chronological order
Snapshots return 60 samples of global throughput history.
### Protocol Detection Cache
Not part of MetricsCollector. Maintained by `HttpProxyService`'s protocol detection system. Injected into the metrics snapshot at read time by `get_metrics()`.
Each entry records: host, port, domain, detected protocol (h1/h2/h3), H3 port, age, last accessed, last probed, suppression flags, cooldown timers, consecutive failure counts.
---
## Instrumentation Points
### TCP Passthrough (`rustproxy-passthrough`)
**Connection lifecycle**`tcp_listener.rs`:
- Accept: `conn_tracker.connection_opened(&ip)` (rate limiter) + `ConnectionTrackerGuard` RAII
- Route match: `metrics.connection_opened(route_id, source_ip)` + `ConnectionGuard` RAII
- Close: Both guards call their respective `_closed()` methods on drop
**Byte recording**`forwarder.rs` (`forward_bidirectional_with_timeouts`):
- Initial peeked data recorded immediately
- Per-chunk in both directions: `record_bytes(n, 0, ...)` / `record_bytes(0, n, ...)`
- Same pattern in `forward_bidirectional_split_with_timeouts` (tcp_listener.rs) for TLS-terminated paths
### HTTP Proxy (`rustproxy-http`)
**Request counting**`proxy_service.rs`:
- `record_http_request()` called once per request after route matching succeeds
**Body byte counting**`counting_body.rs` wrapping:
- Request bodies: `CountingBody::new(body, ..., Direction::In)` — counts client-to-upstream bytes
- Response bodies: `CountingBody::new(body, ..., Direction::Out)` — counts upstream-to-client bytes
- Batched flush every 64KB (`BYTE_FLUSH_THRESHOLD = 65_536`), remainder flushed on drop
- Also updates `connection_activity` atomic (idle watchdog) and `active_requests` counter (streaming detection)
**Backend metrics**`proxy_service.rs`:
- `backend_connection_opened(key, connect_time)` — after TCP/TLS connect succeeds
- `backend_connection_closed(key)` — on teardown
- `backend_connect_error(key)` — TCP/TLS connect failure or timeout
- `backend_handshake_error(key)` — H1/H2 protocol handshake failure
- `backend_request_error(key)` — send_request failure
- `backend_h2_failure(key)` — H2 attempted, fell back to H1
- `backend_pool_hit(key)` / `backend_pool_miss(key)` — connection pool reuse
- `set_backend_protocol(key, proto)` — records detected protocol
**WebSocket**`proxy_service.rs`:
- Does NOT use CountingBody; records bytes directly per-chunk in both directions of the bidirectional copy loop
### QUIC (`rustproxy-passthrough`)
**Connection level**`quic_handler.rs`:
- `connection_opened` / `connection_closed` via `QuicConnGuard` RAII
- `conn_tracker.connection_opened/closed` for rate limiting
**Stream level**:
- For QUIC-to-TCP stream forwarding: `record_bytes(bytes_in, bytes_out, ...)` called once per stream at completion (post-hoc, not per-chunk)
- For HTTP/3: delegates to `HttpProxyService.handle_request()`, so all HTTP proxy metrics apply
**H3 specifics**`h3_service.rs`:
- `ProtocolGuard::frontend("h3")` tracks the H3 connection
- H3 request bodies: `record_bytes(data.len(), 0, ...)` called directly (not CountingBody) since H3 uses `stream.send_data()`
- H3 response bodies: wrapped in CountingBody like HTTP/1 and HTTP/2
### UDP (`rustproxy-passthrough`)
**Session lifecycle**`udp_listener.rs` / `udp_session.rs`:
- `udp_session_opened()` + `connection_opened(route_id, source_ip)` on new session
- `udp_session_closed()` + `connection_closed(route_id, source_ip)` on idle reap or port drain
**Datagram counting**`udp_listener.rs`:
- Inbound: `record_bytes(len, 0, ...)` + `record_datagram_in()`
- Outbound (backend reply): `record_bytes(0, len, ...)` + `record_datagram_out()`
---
## Sampling Loop
`lib.rs` spawns a tokio task at configurable interval (default 1000ms):
```rust
loop {
tokio::select! {
_ = cancel => break,
_ = interval.tick() => {
metrics.sample_all();
conn_tracker.cleanup_stale_timestamps();
http_proxy.cleanup_all_rate_limiters();
}
}
}
```
`sample_all()` performs in one pass:
1. Drains `global_pending_tp_in/out` into global ThroughputTracker, samples
2. Drains per-route pending counters into per-route trackers, samples each
3. Samples idle route trackers (no new data) to advance their window
4. Drains per-IP pending counters into per-IP trackers, samples each
5. Drains `pending_http_requests` into HTTP request throughput tracker
6. Prunes orphaned per-IP entries (bytes/throughput maps with no matching ip_connections key)
7. Prunes orphaned per-backend entries (error/stats maps with no matching active/total key)
---
## Data Flow: Rust to TypeScript
```
MetricsCollector.snapshot()
├── reads all AtomicU64 counters
├── iterates DashMaps (routes, IPs, backends)
├── locks ThroughputTrackers for instant/recent rates + history
└── produces Metrics struct
RustProxy::get_metrics()
├── calls snapshot()
├── enriches with detectedProtocols from HTTP proxy protocol cache
└── returns Metrics
management.rs "getMetrics" IPC command
├── calls get_metrics()
├── serde_json::to_value (camelCase)
└── writes JSON to stdout
RustProxyBridge (TypeScript)
├── reads JSON from Rust process stdout
└── returns parsed object
RustMetricsAdapter
├── setInterval polls bridge.getMetrics() every 1s
├── stores raw JSON in this.cache
└── IMetrics methods read synchronously from cache
SmartProxy.getMetrics()
└── returns the RustMetricsAdapter instance
```
### IPC JSON Shape (Metrics)
```json
{
"activeConnections": 42,
"totalConnections": 1000,
"bytesIn": 123456789,
"bytesOut": 987654321,
"throughputInBytesPerSec": 50000,
"throughputOutBytesPerSec": 80000,
"throughputRecentInBytesPerSec": 45000,
"throughputRecentOutBytesPerSec": 75000,
"routes": {
"<route-id>": {
"activeConnections": 5,
"totalConnections": 100,
"bytesIn": 0, "bytesOut": 0,
"throughputInBytesPerSec": 0, "throughputOutBytesPerSec": 0,
"throughputRecentInBytesPerSec": 0, "throughputRecentOutBytesPerSec": 0
}
},
"ips": {
"<ip>": {
"activeConnections": 2, "totalConnections": 10,
"bytesIn": 0, "bytesOut": 0,
"throughputInBytesPerSec": 0, "throughputOutBytesPerSec": 0,
"domainRequests": {
"example.com": 4821,
"api.example.com": 312
}
}
},
"backends": {
"<host:port>": {
"activeConnections": 3, "totalConnections": 50,
"protocol": "h2",
"connectErrors": 0, "handshakeErrors": 0, "requestErrors": 0,
"totalConnectTimeUs": 150000, "connectCount": 50,
"poolHits": 40, "poolMisses": 10, "h2Failures": 1
}
},
"throughputHistory": [
{ "timestampMs": 1713000000000, "bytesIn": 50000, "bytesOut": 80000 }
],
"totalHttpRequests": 5000,
"httpRequestsPerSec": 100,
"httpRequestsPerSecRecent": 95,
"activeUdpSessions": 0, "totalUdpSessions": 5,
"totalDatagramsIn": 1000, "totalDatagramsOut": 1000,
"frontendProtocols": {
"h1Active": 10, "h1Total": 500,
"h2Active": 5, "h2Total": 200,
"h3Active": 1, "h3Total": 50,
"wsActive": 2, "wsTotal": 30,
"otherActive": 0, "otherTotal": 0
},
"backendProtocols": { "...same shape..." },
"detectedProtocols": [
{
"host": "backend", "port": 443, "domain": "example.com",
"protocol": "h2", "h3Port": 443,
"ageSecs": 120, "lastAccessedSecs": 5, "lastProbedSecs": 120,
"h2Suppressed": false, "h3Suppressed": false,
"h2CooldownRemainingSecs": null, "h3CooldownRemainingSecs": null,
"h2ConsecutiveFailures": null, "h3ConsecutiveFailures": null
}
]
}
```
### IPC JSON Shape (Statistics)
Lightweight administrative summary, fetched on-demand (not polled):
```json
{
"activeConnections": 42,
"totalConnections": 1000,
"routesCount": 5,
"listeningPorts": [80, 443, 8443],
"uptimeSeconds": 86400
}
```
---
## TypeScript Consumer API
`SmartProxy.getMetrics()` returns an `IMetrics` object. All members are synchronous methods reading from the polled cache.
### connections
| Method | Return | Source |
|---|---|---|
| `active()` | `number` | `cache.activeConnections` |
| `total()` | `number` | `cache.totalConnections` |
| `byRoute()` | `Map<string, number>` | `cache.routes[name].activeConnections` |
| `byIP()` | `Map<string, number>` | `cache.ips[ip].activeConnections` |
| `topIPs(limit?)` | `Array<{ip, count}>` | `cache.ips` sorted by active desc, default 10 |
| `domainRequestsByIP()` | `Map<string, Map<string, number>>` | `cache.ips[ip].domainRequests` |
| `topDomainRequests(limit?)` | `Array<{ip, domain, count}>` | Flattened from all IPs, sorted by count desc, default 20 |
| `frontendProtocols()` | `IProtocolDistribution` | `cache.frontendProtocols.*` |
| `backendProtocols()` | `IProtocolDistribution` | `cache.backendProtocols.*` |
### throughput
| Method | Return | Source |
|---|---|---|
| `instant()` | `{in, out}` | `cache.throughputInBytesPerSec/Out` |
| `recent()` | `{in, out}` | `cache.throughputRecentInBytesPerSec/Out` |
| `average()` | `{in, out}` | Falls back to `instant()` (not wired to windowed average) |
| `custom(seconds)` | `{in, out}` | Falls back to `instant()` (not wired) |
| `history(seconds)` | `IThroughputHistoryPoint[]` | `cache.throughputHistory` sliced to last N entries |
| `byRoute(windowSeconds?)` | `Map<string, {in, out}>` | `cache.routes[name].throughputIn/OutBytesPerSec` |
| `byIP(windowSeconds?)` | `Map<string, {in, out}>` | `cache.ips[ip].throughputIn/OutBytesPerSec` |
### requests
| Method | Return | Source |
|---|---|---|
| `perSecond()` | `number` | `cache.httpRequestsPerSec` |
| `perMinute()` | `number` | `cache.httpRequestsPerSecRecent * 60` |
| `total()` | `number` | `cache.totalHttpRequests` (fallback: totalConnections) |
### totals
| Method | Return | Source |
|---|---|---|
| `bytesIn()` | `number` | `cache.bytesIn` |
| `bytesOut()` | `number` | `cache.bytesOut` |
| `connections()` | `number` | `cache.totalConnections` |
### backends
| Method | Return | Source |
|---|---|---|
| `byBackend()` | `Map<string, IBackendMetrics>` | `cache.backends[key].*` with computed `avgConnectTimeMs` and `poolHitRate` |
| `protocols()` | `Map<string, string>` | `cache.backends[key].protocol` |
| `topByErrors(limit?)` | `IBackendMetrics[]` | Sorted by total errors desc |
| `detectedProtocols()` | `IProtocolCacheEntry[]` | `cache.detectedProtocols` passthrough |
`IBackendMetrics`: `{ protocol, activeConnections, totalConnections, connectErrors, handshakeErrors, requestErrors, avgConnectTimeMs, poolHitRate, h2Failures }`
### udp
| Method | Return | Source |
|---|---|---|
| `activeSessions()` | `number` | `cache.activeUdpSessions` |
| `totalSessions()` | `number` | `cache.totalUdpSessions` |
| `datagramsIn()` | `number` | `cache.totalDatagramsIn` |
| `datagramsOut()` | `number` | `cache.totalDatagramsOut` |
### percentiles (stub)
`connectionDuration()` and `bytesTransferred()` always return zeros. Not implemented.
---
## Configuration
```typescript
interface IMetricsConfig {
enabled: boolean; // default true
sampleIntervalMs: number; // default 1000 (1Hz sampling + TS polling)
retentionSeconds: number; // default 3600 (ThroughputTracker capacity)
enableDetailedTracking: boolean;
enablePercentiles: boolean;
cacheResultsMs: number;
prometheusEnabled: boolean; // not wired
prometheusPath: string; // not wired
prometheusPrefix: string; // not wired
}
```
Rust-side config (`MetricsConfig` in `rustproxy-config`):
```rust
pub struct MetricsConfig {
pub enabled: Option<bool>,
pub sample_interval_ms: Option<u64>, // default 1000
pub retention_seconds: Option<u64>, // default 3600
}
```
---
## Design Decisions
**Lock-free hot path.** `record_bytes()` is the most frequently called method (per-chunk in TCP, per-64KB in HTTP). It only touches `AtomicU64` with `Relaxed` ordering and short-circuits zero-byte directions to skip DashMap lookups entirely.
**CountingBody batching.** HTTP body frames are typically 16KB. Flushing to MetricsCollector every 64KB reduces DashMap shard contention by ~4x compared to per-frame recording.
**RAII guards everywhere.** `ConnectionGuard`, `ConnectionTrackerGuard`, `QuicConnGuard`, `ProtocolGuard`, `FrontendProtocolTracker` all use Drop to guarantee counter cleanup on all exit paths including panics.
**Saturating decrements.** Protocol counters use `fetch_update` loops instead of `fetch_sub` to prevent underflow to `u64::MAX` from concurrent close races.
**Bounded memory.** Per-IP entries evicted on last connection close. Per-backend entries evicted on last connection close. Snapshot caps IPs and backends at 100 each. `sample_all()` prunes orphaned entries every second.
**Two-phase throughput.** Pending bytes accumulate in lock-free atomics. The 1Hz cold path drains them into Mutex-guarded ThroughputTrackers. This means the hot path never contends on a Mutex, while the cold path does minimal work (one drain + one sample per tracker).
---
## Known Gaps
| Gap | Status |
|---|---|
| `throughput.average()` / `throughput.custom(seconds)` | Fall back to `instant()`. Not wired to Rust windowed queries. |
| `percentiles.connectionDuration()` / `percentiles.bytesTransferred()` | Stub returning zeros. No histogram in Rust. |
| Prometheus export | Config fields exist but not wired to any exporter. |
| `LogDeduplicator` | Implemented in `rustproxy-metrics` but not connected to any call site. |
| Rate limit hit counters | Rate-limited requests return 429 but no counter is recorded in MetricsCollector. |
| QUIC stream byte counting | Post-hoc (per-stream totals after close), not per-chunk like TCP. |
| Throughput history in snapshot | Capped at 60 samples. TS `history(seconds)` cannot return more than 60 points regardless of `retentionSeconds`. |
| Per-route total connections / bytes | Available in Rust JSON but `IMetrics.connections.byRoute()` only exposes active connections. |
| Per-IP total connections / bytes | Available in Rust JSON but `IMetrics.connections.byIP()` only exposes active connections. |
| IPC response typing | `RustProxyBridge` declares `result: any` for both metrics commands. No type-safe response. |
-339
View File
@@ -1,339 +0,0 @@
use crate::route_types::*;
use crate::tls_types::*;
/// Create a simple HTTP forwarding route.
/// Equivalent to SmartProxy's `createHttpRoute()`.
pub fn create_http_route(
domains: impl Into<DomainSpec>,
target_host: impl Into<String>,
target_port: u16,
) -> RouteConfig {
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(80),
domains: Some(domains.into()),
path: None,
client_ip: None,
transport: None,
tls_version: None,
headers: None,
protocol: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(vec![RouteTarget {
target_match: None,
host: HostSpec::Single(target_host.into()),
port: PortSpec::Fixed(target_port),
tls: None,
websocket: None,
load_balancing: None,
send_proxy_protocol: None,
headers: None,
advanced: None,
backend_transport: None,
priority: None,
}]),
tls: None,
websocket: None,
load_balancing: None,
advanced: None,
options: None,
send_proxy_protocol: None,
udp: None,
},
headers: None,
security: None,
name: None,
description: None,
priority: None,
tags: None,
enabled: None,
}
}
/// Create an HTTPS termination route.
/// Equivalent to SmartProxy's `createHttpsTerminateRoute()`.
pub fn create_https_terminate_route(
domains: impl Into<DomainSpec>,
target_host: impl Into<String>,
target_port: u16,
) -> RouteConfig {
let mut route = create_http_route(domains, target_host, target_port);
route.route_match.ports = PortRange::Single(443);
route.action.tls = Some(RouteTls {
mode: TlsMode::Terminate,
certificate: Some(CertificateSpec::Auto("auto".to_string())),
acme: None,
versions: None,
ciphers: None,
honor_cipher_order: None,
session_timeout: None,
});
route
}
/// Create a TLS passthrough route.
/// Equivalent to SmartProxy's `createHttpsPassthroughRoute()`.
pub fn create_https_passthrough_route(
domains: impl Into<DomainSpec>,
target_host: impl Into<String>,
target_port: u16,
) -> RouteConfig {
let mut route = create_http_route(domains, target_host, target_port);
route.route_match.ports = PortRange::Single(443);
route.action.tls = Some(RouteTls {
mode: TlsMode::Passthrough,
certificate: None,
acme: None,
versions: None,
ciphers: None,
honor_cipher_order: None,
session_timeout: None,
});
route
}
/// Create an HTTP-to-HTTPS redirect route.
/// Equivalent to SmartProxy's `createHttpToHttpsRedirect()`.
pub fn create_http_to_https_redirect(
domains: impl Into<DomainSpec>,
) -> RouteConfig {
let domains = domains.into();
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(80),
domains: Some(domains),
path: None,
client_ip: None,
transport: None,
tls_version: None,
headers: None,
protocol: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: None,
tls: None,
websocket: None,
load_balancing: None,
advanced: Some(RouteAdvanced {
timeout: None,
headers: None,
keep_alive: None,
static_files: None,
test_response: Some(RouteTestResponse {
status: 301,
headers: {
let mut h = std::collections::HashMap::new();
h.insert("Location".to_string(), "https://{domain}{path}".to_string());
h
},
body: String::new(),
}),
url_rewrite: None,
}),
options: None,
send_proxy_protocol: None,
udp: None,
},
headers: None,
security: None,
name: Some("HTTP to HTTPS Redirect".to_string()),
description: None,
priority: None,
tags: None,
enabled: None,
}
}
/// Create a complete HTTPS server with HTTP redirect.
/// Equivalent to SmartProxy's `createCompleteHttpsServer()`.
pub fn create_complete_https_server(
domain: impl Into<String>,
target_host: impl Into<String>,
target_port: u16,
) -> Vec<RouteConfig> {
let domain = domain.into();
let target_host = target_host.into();
vec![
create_http_to_https_redirect(DomainSpec::Single(domain.clone())),
create_https_terminate_route(
DomainSpec::Single(domain),
target_host,
target_port,
),
]
}
/// Create a load balancer route.
/// Equivalent to SmartProxy's `createLoadBalancerRoute()`.
pub fn create_load_balancer_route(
domains: impl Into<DomainSpec>,
targets: Vec<(String, u16)>,
tls: Option<RouteTls>,
) -> RouteConfig {
let route_targets: Vec<RouteTarget> = targets
.into_iter()
.map(|(host, port)| RouteTarget {
target_match: None,
host: HostSpec::Single(host),
port: PortSpec::Fixed(port),
tls: None,
websocket: None,
load_balancing: None,
send_proxy_protocol: None,
headers: None,
advanced: None,
backend_transport: None,
priority: None,
})
.collect();
let port = if tls.is_some() { 443 } else { 80 };
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(port),
domains: Some(domains.into()),
path: None,
client_ip: None,
transport: None,
tls_version: None,
headers: None,
protocol: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(route_targets),
tls,
websocket: None,
load_balancing: Some(RouteLoadBalancing {
algorithm: LoadBalancingAlgorithm::RoundRobin,
health_check: None,
}),
advanced: None,
options: None,
send_proxy_protocol: None,
udp: None,
},
headers: None,
security: None,
name: Some("Load Balancer".to_string()),
description: None,
priority: None,
tags: None,
enabled: None,
}
}
// Convenience conversions for DomainSpec
impl From<&str> for DomainSpec {
fn from(s: &str) -> Self {
DomainSpec::Single(s.to_string())
}
}
impl From<String> for DomainSpec {
fn from(s: String) -> Self {
DomainSpec::Single(s)
}
}
impl From<Vec<String>> for DomainSpec {
fn from(v: Vec<String>) -> Self {
DomainSpec::List(v)
}
}
impl From<Vec<&str>> for DomainSpec {
fn from(v: Vec<&str>) -> Self {
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tls_types::TlsMode;
#[test]
fn test_create_http_route() {
let route = create_http_route("example.com", "localhost", 8080);
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
let domains = route.route_match.domains.as_ref().unwrap().to_vec();
assert_eq!(domains, vec!["example.com"]);
let target = &route.action.targets.as_ref().unwrap()[0];
assert_eq!(target.host.first(), "localhost");
assert_eq!(target.port.resolve(80), 8080);
assert!(route.action.tls.is_none());
}
#[test]
fn test_create_https_terminate_route() {
let route = create_https_terminate_route("api.example.com", "backend", 3000);
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
let tls = route.action.tls.as_ref().unwrap();
assert_eq!(tls.mode, TlsMode::Terminate);
assert!(tls.certificate.as_ref().unwrap().is_auto());
}
#[test]
fn test_create_https_passthrough_route() {
let route = create_https_passthrough_route("secure.example.com", "backend", 443);
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
let tls = route.action.tls.as_ref().unwrap();
assert_eq!(tls.mode, TlsMode::Passthrough);
assert!(tls.certificate.is_none());
}
#[test]
fn test_create_http_to_https_redirect() {
let route = create_http_to_https_redirect("example.com");
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
assert!(route.action.targets.is_none());
let test_response = route.action.advanced.as_ref().unwrap().test_response.as_ref().unwrap();
assert_eq!(test_response.status, 301);
assert!(test_response.headers.contains_key("Location"));
}
#[test]
fn test_create_complete_https_server() {
let routes = create_complete_https_server("example.com", "backend", 8080);
assert_eq!(routes.len(), 2);
// First route is HTTP redirect
assert_eq!(routes[0].route_match.ports.to_ports(), vec![80]);
// Second route is HTTPS terminate
assert_eq!(routes[1].route_match.ports.to_ports(), vec![443]);
}
#[test]
fn test_create_load_balancer_route() {
let targets = vec![
("backend1".to_string(), 8080),
("backend2".to_string(), 8080),
("backend3".to_string(), 8080),
];
let route = create_load_balancer_route("*.example.com", targets, None);
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
assert_eq!(route.action.targets.as_ref().unwrap().len(), 3);
let lb = route.action.load_balancing.as_ref().unwrap();
assert_eq!(lb.algorithm, LoadBalancingAlgorithm::RoundRobin);
}
#[test]
fn test_domain_spec_from_str() {
let spec: DomainSpec = "example.com".into();
assert_eq!(spec.to_vec(), vec!["example.com"]);
}
#[test]
fn test_domain_spec_from_vec() {
let spec: DomainSpec = vec!["a.com", "b.com"].into();
assert_eq!(spec.to_vec(), vec!["a.com", "b.com"]);
}
}
-2
View File
@@ -8,7 +8,6 @@ pub mod proxy_options;
pub mod tls_types;
pub mod security_types;
pub mod validation;
pub mod helpers;
// Re-export all primary types
pub use route_types::*;
@@ -16,4 +15,3 @@ pub use proxy_options::*;
pub use tls_types::*;
pub use security_types::*;
pub use validation::*;
pub use helpers::*;
+278 -19
View File
@@ -129,7 +129,6 @@ pub struct RustProxyOptions {
pub defaults: Option<DefaultConfig>,
// ─── Timeout Settings ────────────────────────────────────────────
/// Timeout for establishing connection to backend (ms), default: 30000
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_timeout: Option<u64>,
@@ -159,7 +158,6 @@ pub struct RustProxyOptions {
pub graceful_shutdown_timeout: Option<u64>,
// ─── Socket Optimization ─────────────────────────────────────────
/// Disable Nagle's algorithm (default: true)
#[serde(skip_serializing_if = "Option::is_none")]
pub no_delay: Option<bool>,
@@ -177,7 +175,6 @@ pub struct RustProxyOptions {
pub max_pending_data_size: Option<u64>,
// ─── Enhanced Features ───────────────────────────────────────────
/// Disable inactivity checking entirely
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_inactivity_check: Option<bool>,
@@ -199,7 +196,6 @@ pub struct RustProxyOptions {
pub enable_randomized_timeouts: Option<bool>,
// ─── Rate Limiting ───────────────────────────────────────────────
/// Maximum simultaneous connections from a single IP
#[serde(skip_serializing_if = "Option::is_none")]
pub max_connections_per_ip: Option<u64>,
@@ -213,7 +209,6 @@ pub struct RustProxyOptions {
pub max_connections: Option<u64>,
// ─── Keep-Alive Settings ─────────────────────────────────────────
/// How to treat keep-alive connections
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_alive_treatment: Option<KeepAliveTreatment>,
@@ -227,7 +222,6 @@ pub struct RustProxyOptions {
pub extended_keep_alive_lifetime: Option<u64>,
// ─── HttpProxy Integration ───────────────────────────────────────
/// Array of ports to forward to HttpProxy
#[serde(skip_serializing_if = "Option::is_none")]
pub use_http_proxy: Option<Vec<u16>>,
@@ -237,13 +231,11 @@ pub struct RustProxyOptions {
pub http_proxy_port: Option<u16>,
// ─── Metrics ─────────────────────────────────────────────────────
/// Metrics configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics: Option<MetricsConfig>,
// ─── ACME ────────────────────────────────────────────────────────
/// Global ACME configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub acme: Option<AcmeOptions>,
@@ -318,7 +310,8 @@ impl RustProxyOptions {
/// Get all unique ports that routes listen on.
pub fn all_listening_ports(&self) -> Vec<u16> {
let mut ports: Vec<u16> = self.routes
let mut ports: Vec<u16> = self
.routes
.iter()
.flat_map(|r| r.listening_ports())
.collect();
@@ -331,12 +324,73 @@ impl RustProxyOptions {
#[cfg(test)]
mod tests {
use super::*;
use crate::helpers::*;
use crate::route_types::*;
use crate::tls_types::*;
fn make_route(domain: &str, host: &str, port: u16, listen_port: u16) -> RouteConfig {
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(listen_port),
domains: Some(DomainSpec::Single(domain.to_string())),
path: None,
client_ip: None,
transport: None,
tls_version: None,
headers: None,
protocol: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(vec![RouteTarget {
target_match: None,
host: HostSpec::Single(host.to_string()),
port: PortSpec::Fixed(port),
tls: None,
websocket: None,
load_balancing: None,
send_proxy_protocol: None,
headers: None,
advanced: None,
backend_transport: None,
priority: None,
}]),
tls: None,
websocket: None,
load_balancing: None,
advanced: None,
options: None,
send_proxy_protocol: None,
udp: None,
},
headers: None,
security: None,
name: None,
description: None,
priority: None,
tags: None,
enabled: None,
}
}
fn make_passthrough_route(domain: &str, host: &str, port: u16) -> RouteConfig {
let mut route = make_route(domain, host, port, 443);
route.action.tls = Some(RouteTls {
mode: TlsMode::Passthrough,
certificate: None,
acme: None,
versions: None,
ciphers: None,
honor_cipher_order: None,
session_timeout: None,
});
route
}
#[test]
fn test_serde_roundtrip_minimal() {
let options = RustProxyOptions {
routes: vec![create_http_route("example.com", "localhost", 8080)],
routes: vec![make_route("example.com", "localhost", 8080, 80)],
..Default::default()
};
let json = serde_json::to_string(&options).unwrap();
@@ -348,8 +402,8 @@ mod tests {
fn test_serde_roundtrip_full() {
let options = RustProxyOptions {
routes: vec![
create_http_route("a.com", "backend1", 8080),
create_https_passthrough_route("b.com", "backend2", 443),
make_route("a.com", "backend1", 8080, 80),
make_passthrough_route("b.com", "backend2", 443),
],
connection_timeout: Some(5000),
socket_timeout: Some(60000),
@@ -374,6 +428,209 @@ mod tests {
assert_eq!(parsed.connection_timeout, Some(5000));
}
#[test]
fn test_deserialize_ts_contract_route_shapes() {
let value = serde_json::json!({
"routes": [{
"name": "contract-route",
"match": {
"ports": [443, { "from": 8443, "to": 8444 }],
"domains": ["api.example.com", "*.example.com"],
"transport": "udp",
"protocol": "http3",
"headers": {
"content-type": "/^application\\/json$/i"
}
},
"action": {
"type": "forward",
"targets": [{
"match": {
"ports": [443],
"path": "/api/*",
"method": ["GET"],
"headers": {
"x-env": "/^(prod|stage)$/"
}
},
"host": ["backend-a", "backend-b"],
"port": "preserve",
"sendProxyProtocol": true,
"backendTransport": "tcp"
}],
"tls": {
"mode": "terminate",
"certificate": "auto"
},
"sendProxyProtocol": true,
"udp": {
"maxSessionsPerIp": 321,
"quic": {
"enableHttp3": true
}
}
},
"security": {
"ipAllowList": [{
"ip": "10.0.0.0/8",
"domains": ["api.example.com"]
}]
}
}],
"preserveSourceIp": true,
"proxyIps": ["10.0.0.1"],
"acceptProxyProtocol": true,
"sendProxyProtocol": true,
"noDelay": true,
"keepAlive": true,
"keepAliveInitialDelay": 1500,
"maxPendingDataSize": 4096,
"disableInactivityCheck": true,
"enableKeepAliveProbes": true,
"enableDetailedLogging": true,
"enableTlsDebugLogging": true,
"enableRandomizedTimeouts": true,
"connectionTimeout": 5000,
"initialDataTimeout": 7000,
"socketTimeout": 9000,
"inactivityCheckInterval": 1100,
"maxConnectionLifetime": 13000,
"inactivityTimeout": 15000,
"gracefulShutdownTimeout": 17000,
"maxConnectionsPerIp": 20,
"connectionRateLimitPerMinute": 30,
"keepAliveTreatment": "extended",
"keepAliveInactivityMultiplier": 2.0,
"extendedKeepAliveLifetime": 19000,
"metrics": {
"enabled": true,
"sampleIntervalMs": 250,
"retentionSeconds": 60
},
"acme": {
"enabled": true,
"email": "ops@example.com",
"environment": "staging",
"useProduction": false,
"skipConfiguredCerts": true,
"renewThresholdDays": 14,
"renewCheckIntervalHours": 12,
"autoRenew": true,
"port": 80
}
});
let options: RustProxyOptions = serde_json::from_value(value).unwrap();
assert_eq!(options.routes.len(), 1);
assert_eq!(options.preserve_source_ip, Some(true));
assert_eq!(options.proxy_ips, Some(vec!["10.0.0.1".to_string()]));
assert_eq!(options.accept_proxy_protocol, Some(true));
assert_eq!(options.send_proxy_protocol, Some(true));
assert_eq!(options.no_delay, Some(true));
assert_eq!(options.keep_alive, Some(true));
assert_eq!(options.keep_alive_initial_delay, Some(1500));
assert_eq!(options.max_pending_data_size, Some(4096));
assert_eq!(options.disable_inactivity_check, Some(true));
assert_eq!(options.enable_keep_alive_probes, Some(true));
assert_eq!(options.enable_detailed_logging, Some(true));
assert_eq!(options.enable_tls_debug_logging, Some(true));
assert_eq!(options.enable_randomized_timeouts, Some(true));
assert_eq!(options.connection_timeout, Some(5000));
assert_eq!(options.initial_data_timeout, Some(7000));
assert_eq!(options.socket_timeout, Some(9000));
assert_eq!(options.inactivity_check_interval, Some(1100));
assert_eq!(options.max_connection_lifetime, Some(13000));
assert_eq!(options.inactivity_timeout, Some(15000));
assert_eq!(options.graceful_shutdown_timeout, Some(17000));
assert_eq!(options.max_connections_per_ip, Some(20));
assert_eq!(options.connection_rate_limit_per_minute, Some(30));
assert_eq!(
options.keep_alive_treatment,
Some(KeepAliveTreatment::Extended)
);
assert_eq!(options.keep_alive_inactivity_multiplier, Some(2.0));
assert_eq!(options.extended_keep_alive_lifetime, Some(19000));
let route = &options.routes[0];
assert_eq!(route.route_match.transport, Some(TransportProtocol::Udp));
assert_eq!(route.route_match.protocol.as_deref(), Some("http3"));
assert_eq!(
route
.route_match
.headers
.as_ref()
.unwrap()
.get("content-type")
.unwrap(),
"/^application\\/json$/i"
);
let target = &route.action.targets.as_ref().unwrap()[0];
assert!(matches!(target.host, HostSpec::List(_)));
assert!(matches!(target.port, PortSpec::Special(ref p) if p == "preserve"));
assert_eq!(target.backend_transport, Some(TransportProtocol::Tcp));
assert_eq!(target.send_proxy_protocol, Some(true));
assert_eq!(
target
.target_match
.as_ref()
.unwrap()
.headers
.as_ref()
.unwrap()
.get("x-env")
.unwrap(),
"/^(prod|stage)$/"
);
assert_eq!(route.action.send_proxy_protocol, Some(true));
assert_eq!(
route.action.udp.as_ref().unwrap().max_sessions_per_ip,
Some(321)
);
assert_eq!(
route
.action
.udp
.as_ref()
.unwrap()
.quic
.as_ref()
.unwrap()
.enable_http3,
Some(true)
);
let allow_list = route
.security
.as_ref()
.unwrap()
.ip_allow_list
.as_ref()
.unwrap();
assert!(matches!(
&allow_list[0],
crate::security_types::IpAllowEntry::DomainScoped { ip, domains }
if ip == "10.0.0.0/8" && domains == &vec!["api.example.com".to_string()]
));
let metrics = options.metrics.as_ref().unwrap();
assert_eq!(metrics.enabled, Some(true));
assert_eq!(metrics.sample_interval_ms, Some(250));
assert_eq!(metrics.retention_seconds, Some(60));
let acme = options.acme.as_ref().unwrap();
assert_eq!(acme.enabled, Some(true));
assert_eq!(acme.email.as_deref(), Some("ops@example.com"));
assert_eq!(acme.environment, Some(AcmeEnvironment::Staging));
assert_eq!(acme.use_production, Some(false));
assert_eq!(acme.skip_configured_certs, Some(true));
assert_eq!(acme.renew_threshold_days, Some(14));
assert_eq!(acme.renew_check_interval_hours, Some(12));
assert_eq!(acme.auto_renew, Some(true));
assert_eq!(acme.port, Some(80));
}
#[test]
fn test_default_timeouts() {
let options = RustProxyOptions::default();
@@ -402,9 +659,9 @@ mod tests {
fn test_all_listening_ports() {
let options = RustProxyOptions {
routes: vec![
create_http_route("a.com", "backend", 8080), // port 80
create_https_passthrough_route("b.com", "backend", 443), // port 443
create_http_route("c.com", "backend", 9090), // port 80 (duplicate)
make_route("a.com", "backend", 8080, 80), // port 80
make_passthrough_route("b.com", "backend", 443), // port 443
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
],
..Default::default()
};
@@ -428,9 +685,11 @@ mod tests {
#[test]
fn test_deserialize_example_json() {
let content = std::fs::read_to_string(
concat!(env!("CARGO_MANIFEST_DIR"), "/../../config/example.json")
).unwrap();
let content = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../config/example.json"
))
.unwrap();
let options: RustProxyOptions = serde_json::from_str(&content).unwrap();
assert_eq!(options.routes.len(), 4);
let ports = options.all_listening_ports();
@@ -1,8 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::tls_types::RouteTls;
use crate::security_types::RouteSecurity;
use crate::tls_types::RouteTls;
// ─── Port Range ──────────────────────────────────────────────────────
@@ -32,12 +32,13 @@ impl PortRange {
pub fn to_ports(&self) -> Vec<u16> {
match self {
PortRange::Single(p) => vec![*p],
PortRange::List(items) => {
items.iter().flat_map(|item| match item {
PortRange::List(items) => items
.iter()
.flat_map(|item| match item {
PortRangeItem::Port(p) => vec![*p],
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
}).collect()
}
})
.collect(),
}
}
}
@@ -79,8 +80,34 @@ impl DomainSpec {
}
}
// Convenience conversions for DomainSpec
impl From<&str> for DomainSpec {
fn from(s: &str) -> Self {
DomainSpec::Single(s.to_string())
}
}
impl From<String> for DomainSpec {
fn from(s: String) -> Self {
DomainSpec::Single(s)
}
}
impl From<Vec<String>> for DomainSpec {
fn from(v: Vec<String>) -> Self {
DomainSpec::List(v)
}
}
impl From<Vec<&str>> for DomainSpec {
fn from(v: Vec<&str>) -> Self {
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
}
}
/// Header match value: either exact string or regex pattern.
/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`.
/// In JSON, all values come as strings. Regex patterns use JS-style literal syntax,
/// e.g. `/^application\/json$/` or `/^application\/json$/i`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HeaderMatchValue {
@@ -103,14 +103,30 @@ pub struct JwtAuthConfig {
pub exclude_paths: Option<Vec<String>>,
}
/// An entry in the IP allow list: either a plain IP/CIDR string
/// or a domain-scoped entry that restricts the IP to specific domains.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum IpAllowEntry {
/// Plain IP/CIDR — allowed for all domains on this route
Plain(String),
/// Domain-scoped — allowed only when the requested domain matches
DomainScoped {
ip: String,
domains: Vec<String>,
},
}
/// Security options for routes.
/// Matches TypeScript: `IRouteSecurity`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteSecurity {
/// IP addresses that are allowed to connect
/// IP addresses that are allowed to connect.
/// Entries can be plain strings (full route access) or objects with
/// `{ ip, domains }` to scope access to specific domains.
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_allow_list: Option<Vec<String>>,
pub ip_allow_list: Option<Vec<IpAllowEntry>>,
/// IP addresses that are blocked from connecting
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_block_list: Option<Vec<String>>,
+43 -1
View File
@@ -104,7 +104,49 @@ mod tests {
use crate::route_types::*;
fn make_valid_route() -> RouteConfig {
crate::helpers::create_http_route("example.com", "localhost", 8080)
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(80),
domains: Some(DomainSpec::Single("example.com".to_string())),
path: None,
client_ip: None,
transport: None,
tls_version: None,
headers: None,
protocol: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(vec![RouteTarget {
target_match: None,
host: HostSpec::Single("localhost".to_string()),
port: PortSpec::Fixed(8080),
tls: None,
websocket: None,
load_balancing: None,
send_proxy_protocol: None,
headers: None,
advanced: None,
backend_transport: None,
priority: None,
}]),
tls: None,
websocket: None,
load_balancing: None,
advanced: None,
options: None,
send_proxy_protocol: None,
udp: None,
},
headers: None,
security: None,
name: None,
description: None,
priority: None,
tags: None,
enabled: None,
}
}
#[test]
+4 -1
View File
@@ -18,7 +18,7 @@ use tracing::{debug, warn};
use rustproxy_config::RouteConfig;
use tokio_util::sync::CancellationToken;
use crate::proxy_service::{ConnActivity, HttpProxyService};
use crate::proxy_service::{ConnActivity, HttpProxyService, ProtocolGuard};
/// HTTP/3 proxy service.
///
@@ -48,6 +48,9 @@ impl H3ProxyService {
let remote_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
debug!("HTTP/3 connection from {} on port {}", remote_addr, port);
// Track frontend H3 connection for the QUIC connection's lifetime.
let _frontend_h3_guard = ProtocolGuard::frontend(Arc::clone(self.http_proxy.metrics()), "h3");
let mut h3_conn: h3::server::Connection<h3_quinn::Connection, Bytes> =
h3::server::builder()
.send_grease(false)
+125 -14
View File
@@ -110,6 +110,68 @@ impl Drop for ActiveRequestGuard {
}
}
/// RAII guard that calls frontend_protocol_closed or backend_protocol_closed on drop.
/// Ensures active protocol counters are decremented on all exit paths.
pub(crate) struct ProtocolGuard {
metrics: Arc<MetricsCollector>,
version: &'static str,
is_frontend: bool,
}
impl ProtocolGuard {
pub fn frontend(metrics: Arc<MetricsCollector>, version: &'static str) -> Self {
metrics.frontend_protocol_opened(version);
Self { metrics, version, is_frontend: true }
}
pub fn backend(metrics: Arc<MetricsCollector>, version: &'static str) -> Self {
metrics.backend_protocol_opened(version);
Self { metrics, version, is_frontend: false }
}
}
impl Drop for ProtocolGuard {
fn drop(&mut self) {
if self.is_frontend {
self.metrics.frontend_protocol_closed(self.version);
} else {
self.metrics.backend_protocol_closed(self.version);
}
}
}
/// Connection-level frontend protocol tracker.
///
/// In `handle_io`, the HTTP protocol (h1 vs h2) is unknown until the first request
/// arrives. This struct uses `OnceLock` so the first request detects the protocol
/// and opens the counter; subsequent requests on the same connection are no-ops.
/// On Drop (when the connection ends), the counter is closed.
pub(crate) struct FrontendProtocolTracker {
metrics: Arc<MetricsCollector>,
proto: std::sync::OnceLock<&'static str>,
}
impl FrontendProtocolTracker {
fn new(metrics: Arc<MetricsCollector>) -> Self {
Self { metrics, proto: std::sync::OnceLock::new() }
}
/// Set the frontend protocol. Only the first call opens the counter.
fn set(&self, proto: &'static str) {
if self.proto.set(proto).is_ok() {
self.metrics.frontend_protocol_opened(proto);
}
}
}
impl Drop for FrontendProtocolTracker {
fn drop(&mut self) {
if let Some(proto) = self.proto.get() {
self.metrics.frontend_protocol_closed(proto);
}
}
}
/// Backend stream that can be either plain TCP or TLS-wrapped.
/// Used for `terminate-and-reencrypt` mode where the backend requires TLS.
pub(crate) enum BackendStream {
@@ -335,6 +397,11 @@ impl HttpProxyService {
self.protocol_cache.snapshot()
}
/// Access the shared metrics collector (used by H3ProxyService for protocol tracking).
pub fn metrics(&self) -> &Arc<MetricsCollector> {
&self.metrics
}
/// Handle an incoming HTTP connection on a plain TCP stream.
pub async fn handle_connection(
self: Arc<Self>,
@@ -379,10 +446,24 @@ impl HttpProxyService {
let active_requests = Arc::new(AtomicU64::new(0));
let start = std::time::Instant::now();
// Connection-level frontend protocol tracker: the first request detects
// h1 vs h2 from req.version() and opens the counter. On connection close
// (when handle_io returns), Drop closes the counter.
let frontend_tracker = Arc::new(FrontendProtocolTracker::new(Arc::clone(&self.metrics)));
let ft_inner = Arc::clone(&frontend_tracker);
let la_inner = Arc::clone(&last_activity);
let ar_inner = Arc::clone(&active_requests);
let cancel_inner = cancel.clone();
let service = hyper::service::service_fn(move |req: Request<Incoming>| {
// Detect frontend protocol from the first request on this connection.
// OnceLock ensures only the first call opens the counter.
let proto: &'static str = match req.version() {
hyper::Version::HTTP_2 => "h2",
_ => "h1",
};
ft_inner.set(proto);
// Mark request start — RAII guard decrements on drop (panic-safe)
la_inner.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
let req_guard = ActiveRequestGuard::new(Arc::clone(&ar_inner));
@@ -545,6 +626,9 @@ impl HttpProxyService {
let route_id = route_match.route.id.as_deref();
let ip_str = ip_string; // reuse from above (avoid redundant to_string())
self.metrics.record_http_request();
if let Some(ref h) = host {
self.metrics.record_ip_domain_request(&ip_str, h);
}
// Apply request filters (IP check, rate limiting, auth)
if let Some(ref security) = route_match.route.security {
@@ -625,6 +709,9 @@ impl HttpProxyService {
.map(|p| p.as_str().eq_ignore_ascii_case("websocket"))
.unwrap_or(false);
// Frontend protocol is tracked at the connection level (handle_io / h3_service).
// WebSocket tunnels additionally get their own "ws" guards in the spawned task.
if is_h1_websocket || is_h2_websocket {
let result = self.handle_websocket_upgrade(
req, peer_addr, &upstream, route_match.route, route_id, &upstream_key, cancel, &ip_str, is_h2_websocket,
@@ -1233,13 +1320,18 @@ impl HttpProxyService {
}
};
tokio::spawn(async move {
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
Ok(Err(e)) => debug!("Upstream connection error: {}", e),
Err(_) => debug!("H1 connection driver timed out after 300s"),
_ => {}
}
});
{
let driver_metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
// Track backend H1 connection for the driver's lifetime
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h1");
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
Ok(Err(e)) => debug!("Upstream connection error: {}", e),
Err(_) => debug!("H1 connection driver timed out after 300s"),
_ => {}
}
});
}
self.forward_h1_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, domain, conn_activity, backend_key).await
}
@@ -1360,7 +1452,10 @@ impl HttpProxyService {
let pool = Arc::clone(&self.connection_pool);
let key = pool_key.clone();
let gen = Arc::clone(&gen_holder);
let driver_metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
// Track backend H2 connection for the driver's lifetime
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h2");
if let Err(e) = conn.await {
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
}
@@ -1659,7 +1754,10 @@ impl HttpProxyService {
let pool = Arc::clone(&self.connection_pool);
let key = pool_key.clone();
let gen = Arc::clone(&gen_holder);
let driver_metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
// Track backend H2 connection for the driver's lifetime
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h2");
if let Err(e) = conn.await {
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
}
@@ -1829,13 +1927,18 @@ impl HttpProxyService {
}
};
tokio::spawn(async move {
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
Ok(Err(e)) => debug!("H1 fallback: upstream connection error: {}", e),
Err(_) => debug!("H1 fallback: connection driver timed out after 300s"),
_ => {}
}
});
{
let driver_metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
// Track backend H1 connection for the driver's lifetime
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h1");
match tokio::time::timeout(std::time::Duration::from_secs(300), conn).await {
Ok(Err(e)) => debug!("H1 fallback: upstream connection error: {}", e),
Err(_) => debug!("H1 fallback: connection driver timed out after 300s"),
_ => {}
}
});
}
let mut upstream_req = Request::builder()
.method(method)
@@ -2383,6 +2486,11 @@ impl HttpProxyService {
selector: upstream_selector,
key: upstream_key_owned.clone(),
};
// Track WebSocket tunnel as "ws" on both frontend and backend.
// Frontend h1/h2 is tracked at the connection level (handle_io); this
// additional "ws" guard captures the tunnel's lifetime independently.
let _frontend_ws_guard = ProtocolGuard::frontend(Arc::clone(&metrics), "ws");
let _backend_ws_guard = ProtocolGuard::backend(Arc::clone(&metrics), "ws");
let client_upgraded = match on_client_upgrade.await {
Ok(upgraded) => upgraded,
@@ -2845,7 +2953,10 @@ impl HttpProxyService {
let driver_pool = Arc::clone(&self.connection_pool);
let driver_pool_key = pool_key.clone();
let driver_gen = Arc::clone(&gen_holder);
let driver_metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
// Track backend H3 connection for the driver's lifetime
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h3");
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);
@@ -35,13 +35,17 @@ impl RequestFilter {
let client_ip = peer_addr.ip();
let request_path = req.uri().path();
// IP filter
// IP filter (domain-aware: extract Host header for domain-scoped entries)
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
let filter = IpFilter::new(allow, block);
let normalized = IpFilter::normalize_ip(&client_ip);
if !filter.is_allowed(&normalized) {
let host = req.headers()
.get("host")
.and_then(|v| v.to_str().ok())
.map(|h| h.split(':').next().unwrap_or(h));
if !filter.is_allowed_for_domain(&normalized, host) {
return Some(error_response(StatusCode::FORBIDDEN, "Access denied"));
}
}
@@ -203,14 +207,15 @@ impl RequestFilter {
}
/// Check IP-based security (for use in passthrough / TCP-level connections).
/// `domain` is the SNI from the TLS handshake (if available) for domain-scoped filtering.
/// Returns true if allowed, false if blocked.
pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr) -> bool {
pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr, domain: Option<&str>) -> bool {
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
let filter = IpFilter::new(allow, block);
let normalized = IpFilter::normalize_ip(client_ip);
filter.is_allowed(&normalized)
filter.is_allowed_for_domain(&normalized, domain)
} else {
true
}
+202 -4
View File
@@ -1,6 +1,6 @@
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
@@ -33,6 +33,9 @@ pub struct Metrics {
pub total_datagrams_out: u64,
// Protocol detection cache snapshot (populated by RustProxy from HttpProxyService)
pub detected_protocols: Vec<ProtocolCacheEntryMetric>,
// Protocol distribution for frontend (client→proxy) and backend (proxy→upstream)
pub frontend_protocols: ProtocolMetrics,
pub backend_protocols: ProtocolMetrics,
}
/// Per-route metrics.
@@ -59,6 +62,8 @@ pub struct IpMetrics {
pub bytes_out: u64,
pub throughput_in_bytes_per_sec: u64,
pub throughput_out_bytes_per_sec: u64,
/// Per-domain request/connection counts for this IP.
pub domain_requests: HashMap<String, u64>,
}
/// Per-backend metrics (keyed by "host:port").
@@ -99,6 +104,23 @@ pub struct ProtocolCacheEntryMetric {
pub h3_consecutive_failures: Option<u32>,
}
/// Protocol distribution metrics for frontend (client→proxy) and backend (proxy→upstream).
/// Tracks active and total counts for each protocol category.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ProtocolMetrics {
pub h1_active: u64,
pub h1_total: u64,
pub h2_active: u64,
pub h2_total: u64,
pub h3_active: u64,
pub h3_total: u64,
pub ws_active: u64,
pub ws_total: u64,
pub other_active: u64,
pub other_total: u64,
}
/// Statistics snapshot.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -119,6 +141,9 @@ const MAX_IPS_IN_SNAPSHOT: usize = 100;
/// Maximum number of backends to include in a snapshot (top by total connections).
const MAX_BACKENDS_IN_SNAPSHOT: usize = 100;
/// Maximum number of distinct domains tracked per IP (prevents subdomain-spray abuse).
const MAX_DOMAINS_PER_IP: usize = 256;
/// Metrics collector tracking connections and throughput.
///
/// Design: The hot path (`record_bytes`) is entirely lock-free — it only touches
@@ -145,6 +170,10 @@ pub struct MetricsCollector {
ip_bytes_out: DashMap<String, AtomicU64>,
ip_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
ip_throughput: DashMap<String, Mutex<ThroughputTracker>>,
/// Per-IP domain request counts: IP → { domain → count }.
/// Tracks which domains each frontend IP has requested (via HTTP Host/SNI).
/// Inner DashMap uses 2 shards to minimise base memory per IP.
ip_domain_requests: DashMap<String, DashMap<String, AtomicU64>>,
// ── Per-backend tracking (keyed by "host:port") ──
backend_active: DashMap<String, AtomicU64>,
@@ -170,6 +199,30 @@ pub struct MetricsCollector {
total_datagrams_in: AtomicU64,
total_datagrams_out: AtomicU64,
// ── Frontend protocol tracking (h1/h2/h3/ws/other) ──
frontend_h1_active: AtomicU64,
frontend_h1_total: AtomicU64,
frontend_h2_active: AtomicU64,
frontend_h2_total: AtomicU64,
frontend_h3_active: AtomicU64,
frontend_h3_total: AtomicU64,
frontend_ws_active: AtomicU64,
frontend_ws_total: AtomicU64,
frontend_other_active: AtomicU64,
frontend_other_total: AtomicU64,
// ── Backend protocol tracking (h1/h2/h3/ws/other) ──
backend_h1_active: AtomicU64,
backend_h1_total: AtomicU64,
backend_h2_active: AtomicU64,
backend_h2_total: AtomicU64,
backend_h3_active: AtomicU64,
backend_h3_total: AtomicU64,
backend_ws_active: AtomicU64,
backend_ws_total: AtomicU64,
backend_other_active: AtomicU64,
backend_other_total: AtomicU64,
// ── Lock-free pending throughput counters (hot path) ──
global_pending_tp_in: AtomicU64,
global_pending_tp_out: AtomicU64,
@@ -203,6 +256,7 @@ impl MetricsCollector {
ip_bytes_out: DashMap::new(),
ip_pending_tp: DashMap::new(),
ip_throughput: DashMap::new(),
ip_domain_requests: DashMap::new(),
backend_active: DashMap::new(),
backend_total: DashMap::new(),
backend_protocol: DashMap::new(),
@@ -221,6 +275,26 @@ impl MetricsCollector {
total_http_requests: AtomicU64::new(0),
pending_http_requests: AtomicU64::new(0),
http_request_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)),
frontend_h1_active: AtomicU64::new(0),
frontend_h1_total: AtomicU64::new(0),
frontend_h2_active: AtomicU64::new(0),
frontend_h2_total: AtomicU64::new(0),
frontend_h3_active: AtomicU64::new(0),
frontend_h3_total: AtomicU64::new(0),
frontend_ws_active: AtomicU64::new(0),
frontend_ws_total: AtomicU64::new(0),
frontend_other_active: AtomicU64::new(0),
frontend_other_total: AtomicU64::new(0),
backend_h1_active: AtomicU64::new(0),
backend_h1_total: AtomicU64::new(0),
backend_h2_active: AtomicU64::new(0),
backend_h2_total: AtomicU64::new(0),
backend_h3_active: AtomicU64::new(0),
backend_h3_total: AtomicU64::new(0),
backend_ws_active: AtomicU64::new(0),
backend_ws_total: AtomicU64::new(0),
backend_other_active: AtomicU64::new(0),
backend_other_total: AtomicU64::new(0),
global_pending_tp_in: AtomicU64::new(0),
global_pending_tp_out: AtomicU64::new(0),
route_pending_tp: DashMap::new(),
@@ -287,6 +361,7 @@ impl MetricsCollector {
self.ip_bytes_out.remove(ip);
self.ip_pending_tp.remove(ip);
self.ip_throughput.remove(ip);
self.ip_domain_requests.remove(ip);
}
}
}
@@ -388,6 +463,37 @@ impl MetricsCollector {
self.pending_http_requests.fetch_add(1, Ordering::Relaxed);
}
/// Record a domain request/connection for a frontend IP.
///
/// Called per HTTP request (with Host header) and per TCP passthrough
/// connection (with SNI domain). The common case (IP + domain both already
/// tracked) is two DashMap reads + one atomic increment — zero allocation.
pub fn record_ip_domain_request(&self, ip: &str, domain: &str) {
// Fast path: IP already tracked, domain already tracked
if let Some(domains) = self.ip_domain_requests.get(ip) {
if let Some(counter) = domains.get(domain) {
counter.fetch_add(1, Ordering::Relaxed);
return;
}
// New domain for this IP — enforce cap
if domains.len() >= MAX_DOMAINS_PER_IP {
return;
}
domains
.entry(domain.to_string())
.or_insert_with(|| AtomicU64::new(0))
.fetch_add(1, Ordering::Relaxed);
return;
}
// New IP — only create if the IP has active connections
if !self.ip_connections.contains_key(ip) {
return;
}
let inner = DashMap::with_capacity_and_shard_amount(4, 2);
inner.insert(domain.to_string(), AtomicU64::new(1));
self.ip_domain_requests.insert(ip.to_string(), inner);
}
// ── UDP session recording methods ──
/// Record a new UDP session opened.
@@ -411,6 +517,63 @@ impl MetricsCollector {
self.total_datagrams_out.fetch_add(1, Ordering::Relaxed);
}
// ── Frontend/backend protocol distribution tracking ──
/// Get the (active, total) counter pair for a frontend protocol.
fn frontend_proto_counters(&self, proto: &str) -> (&AtomicU64, &AtomicU64) {
match proto {
"h2" => (&self.frontend_h2_active, &self.frontend_h2_total),
"h3" => (&self.frontend_h3_active, &self.frontend_h3_total),
"ws" => (&self.frontend_ws_active, &self.frontend_ws_total),
"other" => (&self.frontend_other_active, &self.frontend_other_total),
_ => (&self.frontend_h1_active, &self.frontend_h1_total), // h1 + default
}
}
/// Get the (active, total) counter pair for a backend protocol.
fn backend_proto_counters(&self, proto: &str) -> (&AtomicU64, &AtomicU64) {
match proto {
"h2" => (&self.backend_h2_active, &self.backend_h2_total),
"h3" => (&self.backend_h3_active, &self.backend_h3_total),
"ws" => (&self.backend_ws_active, &self.backend_ws_total),
"other" => (&self.backend_other_active, &self.backend_other_total),
_ => (&self.backend_h1_active, &self.backend_h1_total), // h1 + default
}
}
/// Record a frontend request/connection opened with a given protocol.
pub fn frontend_protocol_opened(&self, proto: &str) {
let (active, total) = self.frontend_proto_counters(proto);
active.fetch_add(1, Ordering::Relaxed);
total.fetch_add(1, Ordering::Relaxed);
}
/// Record a frontend connection closed with a given protocol.
pub fn frontend_protocol_closed(&self, proto: &str) {
let (active, _) = self.frontend_proto_counters(proto);
// Atomic saturating decrement — avoids TOCTOU race where concurrent
// closes could both read val=1, both subtract, wrapping to u64::MAX.
active.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { Some(v - 1) } else { None }
}).ok();
}
/// Record a backend connection opened with a given protocol.
pub fn backend_protocol_opened(&self, proto: &str) {
let (active, total) = self.backend_proto_counters(proto);
active.fetch_add(1, Ordering::Relaxed);
total.fetch_add(1, Ordering::Relaxed);
}
/// Record a backend connection closed with a given protocol.
pub fn backend_protocol_closed(&self, proto: &str) {
let (active, _) = self.backend_proto_counters(proto);
// Atomic saturating decrement — see frontend_protocol_closed for rationale.
active.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { Some(v - 1) } else { None }
}).ok();
}
// ── Per-backend recording methods ──
/// Record a successful backend connection with its connect duration.
@@ -624,6 +787,7 @@ impl MetricsCollector {
self.ip_pending_tp.retain(|k, _| self.ip_connections.contains_key(k));
self.ip_throughput.retain(|k, _| self.ip_connections.contains_key(k));
self.ip_total_connections.retain(|k, _| self.ip_connections.contains_key(k));
self.ip_domain_requests.retain(|k, _| self.ip_connections.contains_key(k));
// Safety-net: prune orphaned backend error/stats entries for backends
// that have no active or total connections (error-only backends).
@@ -731,7 +895,7 @@ impl MetricsCollector {
// Collect per-IP metrics — only IPs with active connections or total > 0,
// capped at top MAX_IPS_IN_SNAPSHOT sorted by active count
let mut ip_entries: Vec<(String, u64, u64, u64, u64, u64, u64)> = Vec::new();
let mut ip_entries: Vec<(String, u64, u64, u64, u64, u64, u64, HashMap<String, u64>)> = Vec::new();
for entry in self.ip_total_connections.iter() {
let ip = entry.key().clone();
let total = entry.value().load(Ordering::Relaxed);
@@ -751,14 +915,23 @@ impl MetricsCollector {
.get(&ip)
.and_then(|entry| entry.value().lock().ok().map(|t| t.instant()))
.unwrap_or((0, 0));
ip_entries.push((ip, active, total, bytes_in, bytes_out, tp_in, tp_out));
// Collect per-domain request counts for this IP
let domain_requests = self.ip_domain_requests
.get(&ip)
.map(|domains| {
domains.iter()
.map(|e| (e.key().clone(), e.value().load(Ordering::Relaxed)))
.collect()
})
.unwrap_or_default();
ip_entries.push((ip, active, total, bytes_in, bytes_out, tp_in, tp_out, domain_requests));
}
// Sort by active connections descending, then cap
ip_entries.sort_by(|a, b| b.1.cmp(&a.1));
ip_entries.truncate(MAX_IPS_IN_SNAPSHOT);
let mut ips = std::collections::HashMap::new();
for (ip, active, total, bytes_in, bytes_out, tp_in, tp_out) in ip_entries {
for (ip, active, total, bytes_in, bytes_out, tp_in, tp_out, domain_requests) in ip_entries {
ips.insert(ip, IpMetrics {
active_connections: active,
total_connections: total,
@@ -766,6 +939,7 @@ impl MetricsCollector {
bytes_out,
throughput_in_bytes_per_sec: tp_in,
throughput_out_bytes_per_sec: tp_out,
domain_requests,
});
}
@@ -866,6 +1040,30 @@ impl MetricsCollector {
total_datagrams_in: self.total_datagrams_in.load(Ordering::Relaxed),
total_datagrams_out: self.total_datagrams_out.load(Ordering::Relaxed),
detected_protocols: vec![],
frontend_protocols: ProtocolMetrics {
h1_active: self.frontend_h1_active.load(Ordering::Relaxed),
h1_total: self.frontend_h1_total.load(Ordering::Relaxed),
h2_active: self.frontend_h2_active.load(Ordering::Relaxed),
h2_total: self.frontend_h2_total.load(Ordering::Relaxed),
h3_active: self.frontend_h3_active.load(Ordering::Relaxed),
h3_total: self.frontend_h3_total.load(Ordering::Relaxed),
ws_active: self.frontend_ws_active.load(Ordering::Relaxed),
ws_total: self.frontend_ws_total.load(Ordering::Relaxed),
other_active: self.frontend_other_active.load(Ordering::Relaxed),
other_total: self.frontend_other_total.load(Ordering::Relaxed),
},
backend_protocols: ProtocolMetrics {
h1_active: self.backend_h1_active.load(Ordering::Relaxed),
h1_total: self.backend_h1_total.load(Ordering::Relaxed),
h2_active: self.backend_h2_active.load(Ordering::Relaxed),
h2_total: self.backend_h2_total.load(Ordering::Relaxed),
h3_active: self.backend_h3_active.load(Ordering::Relaxed),
h3_total: self.backend_h3_total.load(Ordering::Relaxed),
ws_active: self.backend_ws_active.load(Ordering::Relaxed),
ws_total: self.backend_ws_total.load(Ordering::Relaxed),
other_active: self.backend_other_active.load(Ordering::Relaxed),
other_total: self.backend_other_total.load(Ordering::Relaxed),
},
}
}
}
@@ -0,0 +1,329 @@
//! Shared connection registry for selective connection recycling.
//!
//! Tracks active connections across both TCP and QUIC with metadata
//! (source IP, SNI domain, route ID, cancel token) so that connections
//! can be selectively recycled when certificates, security rules, or
//! route targets change.
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::DashMap;
use tokio_util::sync::CancellationToken;
use tracing::info;
use rustproxy_config::RouteSecurity;
use rustproxy_http::request_filter::RequestFilter;
use rustproxy_routing::matchers::domain_matches;
/// Metadata about an active connection.
pub struct ConnectionEntry {
/// Per-connection cancel token (child of per-route token).
pub cancel: CancellationToken,
/// Client source IP.
pub source_ip: IpAddr,
/// SNI domain from TLS handshake (None for non-TLS connections).
pub domain: Option<String>,
/// Route ID this connection was matched to (None if route has no ID).
pub route_id: Option<String>,
}
/// Transport-agnostic registry of active connections.
///
/// Used by both `TcpListenerManager` and `UdpListenerManager` to track
/// connections and enable selective recycling on config changes.
pub struct ConnectionRegistry {
connections: DashMap<u64, ConnectionEntry>,
next_id: AtomicU64,
}
impl ConnectionRegistry {
pub fn new() -> Self {
Self {
connections: DashMap::new(),
next_id: AtomicU64::new(1),
}
}
/// Register a connection and return its ID + RAII guard.
///
/// The guard automatically removes the connection from the registry on drop.
pub fn register(self: &Arc<Self>, entry: ConnectionEntry) -> (u64, ConnectionRegistryGuard) {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
self.connections.insert(id, entry);
let guard = ConnectionRegistryGuard {
registry: Arc::clone(self),
conn_id: id,
};
(id, guard)
}
/// Number of tracked connections (for metrics/debugging).
pub fn len(&self) -> usize {
self.connections.len()
}
/// Recycle connections whose SNI domain matches a renewed certificate domain.
///
/// Uses bidirectional domain matching so that:
/// - Cert `*.example.com` recycles connections for `sub.example.com`
/// - Cert `sub.example.com` recycles connections on routes with `*.example.com`
pub fn recycle_for_cert_change(&self, cert_domain: &str) {
let mut recycled = 0u64;
self.connections.retain(|_, entry| {
let matches = entry.domain.as_deref()
.map(|d| domain_matches(cert_domain, d) || domain_matches(d, cert_domain))
.unwrap_or(false);
if matches {
entry.cancel.cancel();
recycled += 1;
false
} else {
true
}
});
if recycled > 0 {
info!(
"Recycled {} connection(s) for cert change on domain '{}'",
recycled, cert_domain
);
}
}
/// Recycle connections on a route whose security config changed.
///
/// Re-evaluates each connection's source IP against the new security rules.
/// Only connections from now-blocked IPs are terminated; allowed IPs are undisturbed.
pub fn recycle_for_security_change(&self, route_id: &str, new_security: &RouteSecurity) {
let mut recycled = 0u64;
self.connections.retain(|_, entry| {
if entry.route_id.as_deref() == Some(route_id) {
if !RequestFilter::check_ip_security(new_security, &entry.source_ip, entry.domain.as_deref()) {
info!(
"Terminating connection from {} — IP now blocked on route '{}'",
entry.source_ip, route_id
);
entry.cancel.cancel();
recycled += 1;
return false;
}
}
true
});
if recycled > 0 {
info!(
"Recycled {} connection(s) for security change on route '{}'",
recycled, route_id
);
}
}
/// Recycle all connections on a route (e.g., when targets changed).
pub fn recycle_for_route_change(&self, route_id: &str) {
let mut recycled = 0u64;
self.connections.retain(|_, entry| {
if entry.route_id.as_deref() == Some(route_id) {
entry.cancel.cancel();
recycled += 1;
false
} else {
true
}
});
if recycled > 0 {
info!(
"Recycled {} connection(s) for config change on route '{}'",
recycled, route_id
);
}
}
/// Remove connections on routes that no longer exist.
///
/// This complements per-route CancellationToken cancellation —
/// the token cascade handles graceful shutdown, this cleans up the registry.
pub fn cleanup_removed_routes(&self, active_route_ids: &HashSet<String>) {
self.connections.retain(|_, entry| {
match &entry.route_id {
Some(id) => active_route_ids.contains(id),
None => true, // keep connections without a route ID
}
});
}
}
/// RAII guard that removes a connection from the registry on drop.
pub struct ConnectionRegistryGuard {
registry: Arc<ConnectionRegistry>,
conn_id: u64,
}
impl Drop for ConnectionRegistryGuard {
fn drop(&mut self) {
self.registry.connections.remove(&self.conn_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_registry() -> Arc<ConnectionRegistry> {
Arc::new(ConnectionRegistry::new())
}
#[test]
fn test_register_and_guard_cleanup() {
let reg = make_registry();
let token = CancellationToken::new();
let entry = ConnectionEntry {
cancel: token.clone(),
source_ip: "10.0.0.1".parse().unwrap(),
domain: Some("example.com".to_string()),
route_id: Some("route-1".to_string()),
};
let (id, guard) = reg.register(entry);
assert_eq!(reg.len(), 1);
assert!(reg.connections.contains_key(&id));
drop(guard);
assert_eq!(reg.len(), 0);
assert!(!token.is_cancelled());
}
#[test]
fn test_recycle_for_cert_change_exact() {
let reg = make_registry();
let t1 = CancellationToken::new();
let t2 = CancellationToken::new();
let (_, _g1) = reg.register(ConnectionEntry {
cancel: t1.clone(),
source_ip: "10.0.0.1".parse().unwrap(),
domain: Some("api.example.com".to_string()),
route_id: Some("r1".to_string()),
});
let (_, _g2) = reg.register(ConnectionEntry {
cancel: t2.clone(),
source_ip: "10.0.0.2".parse().unwrap(),
domain: Some("other.com".to_string()),
route_id: Some("r2".to_string()),
});
reg.recycle_for_cert_change("api.example.com");
assert!(t1.is_cancelled());
assert!(!t2.is_cancelled());
// Registry retains unmatched entry (guard still alive keeps it too,
// but the retain removed the matched one before guard could)
}
#[test]
fn test_recycle_for_cert_change_wildcard() {
let reg = make_registry();
let t1 = CancellationToken::new();
let t2 = CancellationToken::new();
let (_, _g1) = reg.register(ConnectionEntry {
cancel: t1.clone(),
source_ip: "10.0.0.1".parse().unwrap(),
domain: Some("sub.example.com".to_string()),
route_id: Some("r1".to_string()),
});
let (_, _g2) = reg.register(ConnectionEntry {
cancel: t2.clone(),
source_ip: "10.0.0.2".parse().unwrap(),
domain: Some("other.com".to_string()),
route_id: Some("r2".to_string()),
});
// Wildcard cert should match subdomain
reg.recycle_for_cert_change("*.example.com");
assert!(t1.is_cancelled());
assert!(!t2.is_cancelled());
}
#[test]
fn test_recycle_for_security_change() {
let reg = make_registry();
let t1 = CancellationToken::new();
let t2 = CancellationToken::new();
let (_, _g1) = reg.register(ConnectionEntry {
cancel: t1.clone(),
source_ip: "10.0.0.1".parse().unwrap(),
domain: None,
route_id: Some("r1".to_string()),
});
let (_, _g2) = reg.register(ConnectionEntry {
cancel: t2.clone(),
source_ip: "10.0.0.2".parse().unwrap(),
domain: None,
route_id: Some("r1".to_string()),
});
// Block 10.0.0.1, allow 10.0.0.2
let security = RouteSecurity {
ip_allow_list: None,
ip_block_list: Some(vec!["10.0.0.1".to_string()]),
max_connections: None,
authentication: None,
rate_limit: None,
basic_auth: None,
jwt_auth: None,
};
reg.recycle_for_security_change("r1", &security);
assert!(t1.is_cancelled());
assert!(!t2.is_cancelled());
}
#[test]
fn test_recycle_for_route_change() {
let reg = make_registry();
let t1 = CancellationToken::new();
let t2 = CancellationToken::new();
let (_, _g1) = reg.register(ConnectionEntry {
cancel: t1.clone(),
source_ip: "10.0.0.1".parse().unwrap(),
domain: None,
route_id: Some("r1".to_string()),
});
let (_, _g2) = reg.register(ConnectionEntry {
cancel: t2.clone(),
source_ip: "10.0.0.2".parse().unwrap(),
domain: None,
route_id: Some("r2".to_string()),
});
reg.recycle_for_route_change("r1");
assert!(t1.is_cancelled());
assert!(!t2.is_cancelled());
}
#[test]
fn test_cleanup_removed_routes() {
let reg = make_registry();
let t1 = CancellationToken::new();
let t2 = CancellationToken::new();
let (_, _g1) = reg.register(ConnectionEntry {
cancel: t1.clone(),
source_ip: "10.0.0.1".parse().unwrap(),
domain: None,
route_id: Some("active".to_string()),
});
let (_, _g2) = reg.register(ConnectionEntry {
cancel: t2.clone(),
source_ip: "10.0.0.2".parse().unwrap(),
domain: None,
route_id: Some("removed".to_string()),
});
let mut active = HashSet::new();
active.insert("active".to_string());
reg.cleanup_removed_routes(&active);
// "removed" route entry was cleaned from registry
// (but guard is still alive so len may differ — the retain already removed it)
assert!(!t1.is_cancelled()); // not cancelled by cleanup, only by token cascade
assert!(!t2.is_cancelled()); // cleanup doesn't cancel, just removes from registry
}
}
@@ -10,6 +10,7 @@ pub mod forwarder;
pub mod proxy_protocol;
pub mod tls_handler;
pub mod connection_tracker;
pub mod connection_registry;
pub mod socket_opts;
pub mod udp_session;
pub mod udp_listener;
@@ -21,6 +22,7 @@ pub use forwarder::*;
pub use proxy_protocol::*;
pub use tls_handler::*;
pub use connection_tracker::*;
pub use connection_registry::*;
pub use socket_opts::*;
pub use udp_session::*;
pub use udp_listener::*;
@@ -30,6 +30,7 @@ use rustproxy_routing::{MatchContext, RouteManager};
use rustproxy_http::h3_service::H3ProxyService;
use crate::connection_tracker::ConnectionTracker;
use crate::connection_registry::{ConnectionEntry, ConnectionRegistry};
/// Create a QUIC server endpoint on the given port with the provided TLS config.
///
@@ -350,6 +351,8 @@ pub async fn quic_accept_loop(
cancel: CancellationToken,
h3_service: Option<Arc<H3ProxyService>>,
real_client_map: Option<Arc<DashMap<SocketAddr, SocketAddr>>>,
route_cancels: Arc<DashMap<String, CancellationToken>>,
connection_registry: Arc<ConnectionRegistry>,
) {
loop {
let incoming = tokio::select! {
@@ -406,17 +409,48 @@ pub async fn quic_accept_loop(
}
};
// Check route-level IP security for QUIC (domain from SNI context)
if let Some(ref security) = route.security {
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
security, &ip, ctx.domain,
) {
debug!("QUIC connection from {} blocked by route security", real_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));
// Resolve per-route cancel token (child of global cancel)
let route_cancel = match route_id.as_deref() {
Some(id) => route_cancels.entry(id.to_string())
.or_insert_with(|| cancel.child_token())
.clone(),
None => cancel.child_token(),
};
// Per-connection child token for selective recycling
let conn_cancel = route_cancel.child_token();
// Register in connection registry
let registry = Arc::clone(&connection_registry);
let reg_entry = ConnectionEntry {
cancel: conn_cancel.clone(),
source_ip: ip,
domain: None, // QUIC Initial is encrypted, domain comes later via H3 :authority
route_id: route_id.clone(),
};
let metrics = Arc::clone(&metrics);
let conn_tracker = Arc::clone(&conn_tracker);
let cancel = cancel.child_token();
let h3_svc = h3_service.clone();
let real_client_addr = if real_addr != remote_addr { Some(real_addr) } else { None };
tokio::spawn(async move {
// Register in connection registry (RAII guard removes on drop)
let (_conn_id, _registry_guard) = registry.register(reg_entry);
// RAII guard: ensures metrics/tracker cleanup even on panic
struct QuicConnGuard {
tracker: Arc<ConnectionTracker>,
@@ -439,7 +473,7 @@ pub async fn quic_accept_loop(
route_id,
};
match handle_quic_connection(incoming, route, port, Arc::clone(&metrics), &cancel, h3_svc, real_client_addr).await {
match handle_quic_connection(incoming, route, port, Arc::clone(&metrics), &conn_cancel, h3_svc, real_client_addr).await {
Ok(()) => debug!("QUIC connection from {} completed", real_addr),
Err(e) => debug!("QUIC connection from {} error: {}", real_addr, e),
}
@@ -16,6 +16,7 @@ use crate::sni_parser;
use crate::forwarder;
use crate::tls_handler;
use crate::connection_tracker::ConnectionTracker;
use crate::connection_registry::{ConnectionEntry, ConnectionRegistry};
use crate::socket_opts;
/// RAII guard that decrements the active connection metric on drop.
@@ -42,6 +43,33 @@ impl Drop for ConnectionGuard {
}
}
/// RAII guard for frontend+backend protocol distribution tracking.
/// Calls the appropriate _closed methods on drop for both frontend and backend.
struct ProtocolGuard {
metrics: Arc<MetricsCollector>,
frontend_proto: Option<&'static str>,
backend_proto: Option<&'static str>,
}
impl ProtocolGuard {
fn new(metrics: Arc<MetricsCollector>, frontend: &'static str, backend: &'static str) -> Self {
metrics.frontend_protocol_opened(frontend);
metrics.backend_protocol_opened(backend);
Self { metrics, frontend_proto: Some(frontend), backend_proto: Some(backend) }
}
}
impl Drop for ProtocolGuard {
fn drop(&mut self) {
if let Some(proto) = self.frontend_proto {
self.metrics.frontend_protocol_closed(proto);
}
if let Some(proto) = self.backend_proto {
self.metrics.backend_protocol_closed(proto);
}
}
}
/// RAII guard that calls ConnectionTracker::connection_closed on drop.
/// Ensures per-IP tracking is cleaned up on ALL exit paths — normal, error, or panic.
struct ConnectionTrackerGuard {
@@ -166,6 +194,8 @@ pub struct TcpListenerManager {
/// Per-route cancellation tokens (child of cancel_token).
/// When a route is removed, its token is cancelled, terminating all connections on that route.
route_cancels: Arc<DashMap<String, CancellationToken>>,
/// Shared connection registry for selective recycling on config changes.
connection_registry: Arc<ConnectionRegistry>,
}
impl TcpListenerManager {
@@ -205,6 +235,7 @@ impl TcpListenerManager {
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
conn_semaphore: Arc::new(tokio::sync::Semaphore::new(max_conns)),
route_cancels: Arc::new(DashMap::new()),
connection_registry: Arc::new(ConnectionRegistry::new()),
}
}
@@ -244,6 +275,7 @@ impl TcpListenerManager {
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
conn_semaphore: Arc::new(tokio::sync::Semaphore::new(max_conns)),
route_cancels: Arc::new(DashMap::new()),
connection_registry: Arc::new(ConnectionRegistry::new()),
}
}
@@ -328,12 +360,13 @@ impl TcpListenerManager {
let relay = Arc::clone(&self.socket_handler_relay);
let semaphore = Arc::clone(&self.conn_semaphore);
let route_cancels = Arc::clone(&self.route_cancels);
let connection_registry = Arc::clone(&self.connection_registry);
let handle = tokio::spawn(async move {
Self::accept_loop(
listener, port, route_manager_swap, metrics, tls_configs,
shared_tls_acceptor, http_proxy, conn_config, conn_tracker, cancel, relay,
semaphore, route_cancels,
semaphore, route_cancels, connection_registry,
).await;
});
@@ -446,6 +479,16 @@ impl TcpListenerManager {
&self.metrics
}
/// Get a reference to the shared connection registry.
pub fn connection_registry(&self) -> &Arc<ConnectionRegistry> {
&self.connection_registry
}
/// Get a reference to the per-route cancellation tokens.
pub fn route_cancels(&self) -> &Arc<DashMap<String, CancellationToken>> {
&self.route_cancels
}
/// Accept loop for a single port.
async fn accept_loop(
listener: TcpListener,
@@ -461,6 +504,7 @@ impl TcpListenerManager {
socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>,
conn_semaphore: Arc<tokio::sync::Semaphore>,
route_cancels: Arc<DashMap<String, CancellationToken>>,
connection_registry: Arc<ConnectionRegistry>,
) {
loop {
tokio::select! {
@@ -514,6 +558,7 @@ impl TcpListenerManager {
let cn = cancel.clone();
let sr = Arc::clone(&socket_handler_relay);
let rc = Arc::clone(&route_cancels);
let cr = Arc::clone(&connection_registry);
debug!("Accepted connection from {} on port {}", peer_addr, port);
tokio::spawn(async move {
@@ -522,7 +567,7 @@ impl TcpListenerManager {
// RAII guard ensures connection_closed is called on all paths
let _ct_guard = ConnectionTrackerGuard::new(ct, ip);
let result = Self::handle_connection(
stream, port, peer_addr, rm, m, tc, sa, hp, cc, cn, sr, rc,
stream, port, peer_addr, rm, m, tc, sa, hp, cc, cn, sr, rc, cr,
).await;
if let Err(e) = result {
warn!("Connection error from {}: {}", peer_addr, e);
@@ -553,6 +598,7 @@ impl TcpListenerManager {
cancel: CancellationToken,
socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>,
route_cancels: Arc<DashMap<String, CancellationToken>>,
connection_registry: Arc<ConnectionRegistry>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::io::AsyncReadExt;
@@ -672,17 +718,29 @@ impl TcpListenerManager {
let route_id = quick_match.route.id.as_deref();
// Resolve per-route cancel token (child of global cancel)
let conn_cancel = match route_id {
let route_cancel = match route_id {
Some(id) => route_cancels.entry(id.to_string())
.or_insert_with(|| cancel.child_token())
.clone(),
None => cancel.clone(),
};
// Per-connection child token for selective recycling
let conn_cancel = route_cancel.child_token();
// Check route-level IP security
// Register in connection registry for selective recycling
let (_conn_id, _registry_guard) = connection_registry.register(
ConnectionEntry {
cancel: conn_cancel.clone(),
source_ip: peer_addr.ip(),
domain: None, // fast path has no domain
route_id: route_id.map(|s| s.to_string()),
},
);
// Check route-level IP security (fast path: no SNI available)
if let Some(ref security) = quick_match.route.security {
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
security, &peer_addr.ip(),
security, &peer_addr.ip(), None,
) {
warn!("Connection from {} blocked by route security", peer_addr);
return Ok(());
@@ -852,18 +910,31 @@ impl TcpListenerManager {
// Resolve per-route cancel token (child of global cancel).
// When this route is removed via updateRoutes, the token is cancelled,
// terminating all connections on this route.
let cancel = match route_id {
let route_cancel = match route_id {
Some(id) => route_cancels.entry(id.to_string())
.or_insert_with(|| cancel.child_token())
.clone(),
None => cancel,
};
// Per-connection child token for selective recycling
let cancel = route_cancel.child_token();
// Check route-level IP security for passthrough connections
// Register in connection registry for selective recycling
let (_conn_id, _registry_guard) = connection_registry.register(
ConnectionEntry {
cancel: cancel.clone(),
source_ip: peer_addr.ip(),
domain: domain.clone(),
route_id: route_id.map(|s| s.to_string()),
},
);
// Check route-level IP security for passthrough connections (SNI available)
if let Some(ref security) = route_match.route.security {
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
security,
&peer_addr.ip(),
domain.as_deref(),
) {
warn!("Connection from {} blocked by route security", peer_addr);
return Ok(());
@@ -872,6 +943,9 @@ impl TcpListenerManager {
// Track connection in metrics — guard ensures connection_closed on all exit paths
metrics.connection_opened(route_id, Some(&ip_str));
if let Some(d) = effective_domain {
metrics.record_ip_domain_request(&ip_str, d);
}
let _conn_guard = ConnectionGuard::new(Arc::clone(&metrics), route_id, Some(&ip_str));
// Check if this is a socket-handler route that should be relayed to TypeScript
@@ -981,6 +1055,9 @@ impl TcpListenerManager {
peer_addr, target_host, target_port, domain
);
// Track as "other" protocol (non-HTTP passthrough)
let _proto_guard = ProtocolGuard::new(Arc::clone(&metrics), "other", "other");
let mut actual_buf = vec![0u8; n];
stream.read_exact(&mut actual_buf).await?;
@@ -1047,6 +1124,8 @@ impl TcpListenerManager {
"TLS Terminate + TCP: {} -> {}:{} (domain: {:?})",
peer_addr, target_host, target_port, domain
);
// Track as "other" protocol (TLS-terminated non-HTTP)
let _proto_guard = ProtocolGuard::new(Arc::clone(&metrics), "other", "other");
// Raw TCP forwarding of decrypted stream
let backend = match tokio::time::timeout(
connect_timeout,
@@ -1133,6 +1212,8 @@ impl TcpListenerManager {
"TLS Terminate+Reencrypt + TCP: {} -> {}:{}",
peer_addr, target_host, target_port
);
// Track as "other" protocol (TLS-terminated non-HTTP, re-encrypted)
let _proto_guard = ProtocolGuard::new(Arc::clone(&metrics), "other", "other");
Self::handle_tls_reencrypt_tunnel(
buf_stream, &target_host, target_port,
peer_addr, Arc::clone(&metrics), route_id,
@@ -1149,6 +1230,8 @@ impl TcpListenerManager {
Ok(())
} else {
// Plain TCP forwarding (non-HTTP)
// Track as "other" protocol (plain TCP, non-HTTP)
let _proto_guard = ProtocolGuard::new(Arc::clone(&metrics), "other", "other");
let mut backend = match tokio::time::timeout(
connect_timeout,
tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)),
@@ -28,6 +28,8 @@ use rustproxy_routing::{MatchContext, RouteManager};
use rustproxy_http::h3_service::H3ProxyService;
use crate::connection_registry::ConnectionRegistry;
use crate::connection_tracker::ConnectionTracker;
use crate::udp_session::{SessionKey, UdpSession, UdpSessionConfig, UdpSessionTable};
@@ -56,6 +58,10 @@ pub struct UdpListenerManager {
/// Trusted proxy IPs that may send PROXY protocol v2 headers.
/// When non-empty, PROXY v2 detection is enabled on both raw UDP and QUIC paths.
proxy_ips: Arc<Vec<IpAddr>>,
/// Per-route cancellation tokens (shared with TcpListenerManager).
route_cancels: Arc<DashMap<String, CancellationToken>>,
/// Shared connection registry for selective recycling.
connection_registry: Arc<ConnectionRegistry>,
}
impl Drop for UdpListenerManager {
@@ -76,6 +82,8 @@ impl UdpListenerManager {
metrics: Arc<MetricsCollector>,
conn_tracker: Arc<ConnectionTracker>,
cancel_token: CancellationToken,
route_cancels: Arc<DashMap<String, CancellationToken>>,
connection_registry: Arc<ConnectionRegistry>,
) -> Self {
Self {
listeners: HashMap::new(),
@@ -89,6 +97,8 @@ impl UdpListenerManager {
relay_reader_cancel: None,
h3_service: None,
proxy_ips: Arc::new(Vec::new()),
route_cancels,
connection_registry,
}
}
@@ -152,6 +162,8 @@ impl UdpListenerManager {
self.cancel_token.child_token(),
self.h3_service.clone(),
None,
Arc::clone(&self.route_cancels),
Arc::clone(&self.connection_registry),
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
info!("QUIC endpoint started on port {}", port);
@@ -173,6 +185,8 @@ impl UdpListenerManager {
self.cancel_token.child_token(),
self.h3_service.clone(),
Some(relay.real_client_map),
Arc::clone(&self.route_cancels),
Arc::clone(&self.connection_registry),
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
info!("QUIC endpoint with PROXY relay started on port {}", port);
@@ -356,6 +370,8 @@ impl UdpListenerManager {
self.cancel_token.child_token(),
self.h3_service.clone(),
None,
Arc::clone(&self.route_cancels),
Arc::clone(&self.connection_registry),
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
Ok(())
@@ -379,6 +395,8 @@ impl UdpListenerManager {
self.cancel_token.child_token(),
self.h3_service.clone(),
Some(relay.real_client_map),
Arc::clone(&self.route_cancels),
Arc::clone(&self.connection_registry),
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
Ok(())
@@ -1,5 +1,42 @@
use std::collections::HashMap;
use regex::Regex;
use std::collections::HashMap;
fn compile_regex_pattern(pattern: &str) -> Option<Regex> {
if !pattern.starts_with('/') {
return None;
}
let last_slash = pattern.rfind('/')?;
if last_slash == 0 {
return None;
}
let regex_body = &pattern[1..last_slash];
let flags = &pattern[last_slash + 1..];
let mut inline_flags = String::new();
for flag in flags.chars() {
match flag {
'i' | 'm' | 's' | 'u' => {
if !inline_flags.contains(flag) {
inline_flags.push(flag);
}
}
'g' => {
// Global has no effect for single header matching.
}
_ => return None,
}
}
let compiled = if inline_flags.is_empty() {
regex_body.to_string()
} else {
format!("(?{}){}", inline_flags, regex_body)
};
Regex::new(&compiled).ok()
}
/// Match HTTP headers against a set of patterns.
///
@@ -24,16 +61,15 @@ pub fn headers_match(
None => return false, // Required header not present
};
// Check if pattern is a regex (surrounded by /)
if pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() > 2 {
let regex_str = &pattern[1..pattern.len() - 1];
match Regex::new(regex_str) {
Ok(re) => {
// Check if pattern is a regex literal (/pattern/ or /pattern/flags)
if pattern.starts_with('/') && pattern.len() > 2 {
match compile_regex_pattern(pattern) {
Some(re) => {
if !re.is_match(header_value) {
return false;
}
}
Err(_) => {
None => {
// Invalid regex, fall back to exact match
if header_value != pattern {
return false;
@@ -85,6 +121,24 @@ mod tests {
assert!(headers_match(&patterns, &headers));
}
#[test]
fn test_regex_header_match_with_flags() {
let patterns: HashMap<String, String> = {
let mut m = HashMap::new();
m.insert(
"Content-Type".to_string(),
"/^application\\/json$/i".to_string(),
);
m
};
let headers: HashMap<String, String> = {
let mut m = HashMap::new();
m.insert("content-type".to_string(), "Application/JSON".to_string());
m
};
assert!(headers_match(&patterns, &headers));
}
#[test]
fn test_missing_header() {
let patterns: HashMap<String, String> = {
@@ -281,6 +281,11 @@ impl RouteManager {
.unwrap_or_default()
}
/// Get all enabled routes.
pub fn routes(&self) -> &[RouteConfig] {
&self.routes
}
/// Get the total number of enabled routes.
pub fn route_count(&self) -> usize {
self.routes.len()
+194 -37
View File
@@ -2,12 +2,24 @@ use ipnet::IpNet;
use std::net::IpAddr;
use std::str::FromStr;
use rustproxy_config::IpAllowEntry;
/// IP filter supporting CIDR ranges, wildcards, and exact matches.
/// Supports domain-scoped allow entries that restrict an IP to specific domains.
pub struct IpFilter {
/// Plain allow entries — IP allowed for any domain on the route
allow_list: Vec<IpPattern>,
/// Domain-scoped allow entries — IP allowed only for matching domains
domain_scoped: Vec<DomainScopedEntry>,
block_list: Vec<IpPattern>,
}
/// A domain-scoped allow entry: IP + list of allowed domain patterns.
struct DomainScopedEntry {
pattern: IpPattern,
domains: Vec<String>,
}
/// Represents an IP pattern for matching.
#[derive(Debug)]
enum IpPattern {
@@ -31,10 +43,6 @@ impl IpPattern {
if let Ok(addr) = IpAddr::from_str(s) {
return IpPattern::Exact(addr);
}
// Try as CIDR by appending default prefix
if let Ok(addr) = IpAddr::from_str(s) {
return IpPattern::Exact(addr);
}
// Fallback: treat as exact, will never match an invalid string
IpPattern::Exact(IpAddr::from_str("0.0.0.0").unwrap())
}
@@ -48,19 +56,56 @@ impl IpPattern {
}
}
/// Simple domain pattern matching (exact, `*`, or `*.suffix`).
fn domain_matches_pattern(pattern: &str, domain: &str) -> bool {
let p = pattern.trim();
let d = domain.trim();
if p == "*" {
return true;
}
if p.eq_ignore_ascii_case(d) {
return true;
}
if p.starts_with("*.") {
let suffix = &p[1..]; // e.g., ".abc.xyz"
d.len() > suffix.len()
&& d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
} else {
false
}
}
impl IpFilter {
/// Create a new IP filter from allow and block lists.
pub fn new(allow_list: &[String], block_list: &[String]) -> Self {
/// Create a new IP filter from allow entries and a block list.
pub fn new(allow_entries: &[IpAllowEntry], block_list: &[String]) -> Self {
let mut allow_list = Vec::new();
let mut domain_scoped = Vec::new();
for entry in allow_entries {
match entry {
IpAllowEntry::Plain(ip) => {
allow_list.push(IpPattern::parse(ip));
}
IpAllowEntry::DomainScoped { ip, domains } => {
domain_scoped.push(DomainScopedEntry {
pattern: IpPattern::parse(ip),
domains: domains.clone(),
});
}
}
}
Self {
allow_list: allow_list.iter().map(|s| IpPattern::parse(s)).collect(),
allow_list,
domain_scoped,
block_list: block_list.iter().map(|s| IpPattern::parse(s)).collect(),
}
}
/// Check if an IP is allowed.
/// If allow_list is non-empty, IP must match at least one entry.
/// If block_list is non-empty, IP must NOT match any entry.
pub fn is_allowed(&self, ip: &IpAddr) -> bool {
/// Check if an IP is allowed, considering domain-scoped entries.
/// If `domain` is Some, domain-scoped entries are evaluated against it.
/// If `domain` is None, only plain allow entries are considered.
pub fn is_allowed_for_domain(&self, ip: &IpAddr, domain: Option<&str>) -> bool {
// Check block list first
if !self.block_list.is_empty() {
for pattern in &self.block_list {
@@ -70,14 +115,36 @@ impl IpFilter {
}
}
// If allow list is non-empty, must match at least one
if !self.allow_list.is_empty() {
return self.allow_list.iter().any(|p| p.matches(ip));
// If there are any allow entries (plain or domain-scoped), IP must match
let has_any_allow = !self.allow_list.is_empty() || !self.domain_scoped.is_empty();
if has_any_allow {
// Check plain allow list — grants access to entire route
if self.allow_list.iter().any(|p| p.matches(ip)) {
return true;
}
// Check domain-scoped entries — grants access only if domain matches
if let Some(req_domain) = domain {
for entry in &self.domain_scoped {
if entry.pattern.matches(ip) {
if entry.domains.iter().any(|d| domain_matches_pattern(d, req_domain)) {
return true;
}
}
}
}
return false;
}
true
}
/// Check if an IP is allowed (backwards-compat wrapper, no domain context).
pub fn is_allowed(&self, ip: &IpAddr) -> bool {
self.is_allowed_for_domain(ip, None)
}
/// Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x -> x.x.x.x)
pub fn normalize_ip(ip: &IpAddr) -> IpAddr {
match ip {
@@ -97,19 +164,28 @@ impl IpFilter {
mod tests {
use super::*;
fn plain(s: &str) -> IpAllowEntry {
IpAllowEntry::Plain(s.to_string())
}
fn scoped(ip: &str, domains: &[&str]) -> IpAllowEntry {
IpAllowEntry::DomainScoped {
ip: ip.to_string(),
domains: domains.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn test_empty_lists_allow_all() {
let filter = IpFilter::new(&[], &[]);
let ip: IpAddr = "192.168.1.1".parse().unwrap();
assert!(filter.is_allowed(&ip));
assert!(filter.is_allowed_for_domain(&ip, Some("example.com")));
}
#[test]
fn test_allow_list_exact() {
let filter = IpFilter::new(
&["10.0.0.1".to_string()],
&[],
);
fn test_plain_allow_list_exact() {
let filter = IpFilter::new(&[plain("10.0.0.1")], &[]);
let allowed: IpAddr = "10.0.0.1".parse().unwrap();
let denied: IpAddr = "10.0.0.2".parse().unwrap();
assert!(filter.is_allowed(&allowed));
@@ -117,11 +193,8 @@ mod tests {
}
#[test]
fn test_allow_list_cidr() {
let filter = IpFilter::new(
&["10.0.0.0/8".to_string()],
&[],
);
fn test_plain_allow_list_cidr() {
let filter = IpFilter::new(&[plain("10.0.0.0/8")], &[]);
let allowed: IpAddr = "10.255.255.255".parse().unwrap();
let denied: IpAddr = "192.168.1.1".parse().unwrap();
assert!(filter.is_allowed(&allowed));
@@ -130,10 +203,7 @@ mod tests {
#[test]
fn test_block_list() {
let filter = IpFilter::new(
&[],
&["192.168.1.100".to_string()],
);
let filter = IpFilter::new(&[], &["192.168.1.100".to_string()]);
let blocked: IpAddr = "192.168.1.100".parse().unwrap();
let allowed: IpAddr = "192.168.1.101".parse().unwrap();
assert!(!filter.is_allowed(&blocked));
@@ -143,7 +213,7 @@ mod tests {
#[test]
fn test_block_trumps_allow() {
let filter = IpFilter::new(
&["10.0.0.0/8".to_string()],
&[plain("10.0.0.0/8")],
&["10.0.0.5".to_string()],
);
let blocked: IpAddr = "10.0.0.5".parse().unwrap();
@@ -154,20 +224,14 @@ mod tests {
#[test]
fn test_wildcard_allow() {
let filter = IpFilter::new(
&["*".to_string()],
&[],
);
let filter = IpFilter::new(&[plain("*")], &[]);
let ip: IpAddr = "1.2.3.4".parse().unwrap();
assert!(filter.is_allowed(&ip));
}
#[test]
fn test_wildcard_block() {
let filter = IpFilter::new(
&[],
&["*".to_string()],
);
let filter = IpFilter::new(&[], &["*".to_string()]);
let ip: IpAddr = "1.2.3.4".parse().unwrap();
assert!(!filter.is_allowed(&ip));
}
@@ -186,4 +250,97 @@ mod tests {
let normalized = IpFilter::normalize_ip(&ip);
assert_eq!(normalized, ip);
}
// Domain-scoped tests
#[test]
fn test_domain_scoped_allows_matching_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&[],
);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
}
#[test]
fn test_domain_scoped_denies_non_matching_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&[],
);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(!filter.is_allowed_for_domain(&ip, Some("app.abc.xyz")));
}
#[test]
fn test_domain_scoped_denies_without_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&[],
);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
// Without domain context, domain-scoped entries cannot match
assert!(!filter.is_allowed_for_domain(&ip, None));
}
#[test]
fn test_domain_scoped_wildcard_domain() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["*.abc.xyz"])],
&[],
);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
assert!(filter.is_allowed_for_domain(&ip, Some("app.abc.xyz")));
assert!(!filter.is_allowed_for_domain(&ip, Some("other.com")));
}
#[test]
fn test_plain_and_domain_scoped_coexist() {
let filter = IpFilter::new(
&[
plain("1.2.3.4"), // full route access
scoped("10.8.0.2", &["outline.abc.xyz"]), // scoped access
],
&[],
);
let admin: IpAddr = "1.2.3.4".parse().unwrap();
let vpn: IpAddr = "10.8.0.2".parse().unwrap();
let other: IpAddr = "9.9.9.9".parse().unwrap();
// Admin IP has full access
assert!(filter.is_allowed_for_domain(&admin, Some("anything.abc.xyz")));
assert!(filter.is_allowed_for_domain(&admin, Some("outline.abc.xyz")));
// VPN IP only has scoped access
assert!(filter.is_allowed_for_domain(&vpn, Some("outline.abc.xyz")));
assert!(!filter.is_allowed_for_domain(&vpn, Some("app.abc.xyz")));
// Unknown IP denied
assert!(!filter.is_allowed_for_domain(&other, Some("outline.abc.xyz")));
}
#[test]
fn test_block_trumps_domain_scoped() {
let filter = IpFilter::new(
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
&["10.8.0.2".to_string()],
);
let ip: IpAddr = "10.8.0.2".parse().unwrap();
assert!(!filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz")));
}
#[test]
fn test_domain_matches_pattern_fn() {
assert!(domain_matches_pattern("example.com", "example.com"));
assert!(domain_matches_pattern("*.abc.xyz", "outline.abc.xyz"));
assert!(domain_matches_pattern("*.abc.xyz", "app.abc.xyz"));
assert!(!domain_matches_pattern("*.abc.xyz", "abc.xyz")); // suffix only, not exact parent
assert!(domain_matches_pattern("*", "anything.com"));
assert!(!domain_matches_pattern("outline.abc.xyz", "app.abc.xyz"));
// Case insensitive
assert!(domain_matches_pattern("*.ABC.XYZ", "outline.abc.xyz"));
}
}
+105 -6
View File
@@ -7,14 +7,40 @@
//!
//! ```rust,no_run
//! use rustproxy::RustProxy;
//! use rustproxy_config::{RustProxyOptions, create_https_passthrough_route};
//! use rustproxy_config::*;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let options = RustProxyOptions {
//! routes: vec![
//! create_https_passthrough_route("example.com", "backend", 443),
//! ],
//! routes: vec![RouteConfig {
//! id: None,
//! route_match: RouteMatch {
//! ports: PortRange::Single(443),
//! domains: Some(DomainSpec::Single("example.com".to_string())),
//! path: None, client_ip: None, transport: None,
//! tls_version: None, headers: None, protocol: None,
//! },
//! action: RouteAction {
//! action_type: RouteActionType::Forward,
//! targets: Some(vec![RouteTarget {
//! target_match: None,
//! host: HostSpec::Single("backend".to_string()),
//! port: PortSpec::Fixed(443),
//! tls: None, websocket: None, load_balancing: None,
//! send_proxy_protocol: None, headers: None, advanced: None,
//! backend_transport: None, priority: None,
//! }]),
//! tls: Some(RouteTls {
//! mode: TlsMode::Passthrough,
//! certificate: None, acme: None, versions: None,
//! ciphers: None, honor_cipher_order: None, session_timeout: None,
//! }),
//! websocket: None, load_balancing: None, advanced: None,
//! options: None, send_proxy_protocol: None, udp: None,
//! },
//! headers: None, security: None, name: None, description: None,
//! priority: None, tags: None, enabled: None,
//! }],
//! ..Default::default()
//! };
//!
@@ -172,7 +198,9 @@ impl RustProxy {
};
if let Some(ref allow_list) = default_security.ip_allow_list {
security.ip_allow_list = Some(allow_list.clone());
security.ip_allow_list = Some(
allow_list.iter().map(|s| rustproxy_config::IpAllowEntry::Plain(s.clone())).collect()
);
}
if let Some(ref block_list) = default_security.ip_block_list {
security.ip_block_list = Some(block_list.clone());
@@ -330,12 +358,17 @@ impl RustProxy {
// Bind UDP ports (if any)
if !udp_ports.is_empty() {
let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone();
let tcp_mgr = self.listener_manager.as_ref().unwrap();
let conn_tracker = tcp_mgr.conn_tracker().clone();
let route_cancels = tcp_mgr.route_cancels().clone();
let connection_registry = tcp_mgr.connection_registry().clone();
let mut udp_mgr = UdpListenerManager::new(
Arc::clone(&*self.route_table.load()),
Arc::clone(&self.metrics),
conn_tracker,
self.cancel_token.clone(),
route_cancels,
connection_registry,
);
udp_mgr.set_proxy_ips(udp_proxy_ips.clone());
@@ -681,6 +714,9 @@ impl RustProxy {
.collect();
self.metrics.retain_backends(&active_backends);
// Capture old route manager for diff-based connection recycling
let old_manager = self.route_table.load_full();
// Atomically swap the route table
let new_manager = Arc::new(new_manager);
self.route_table.store(Arc::clone(&new_manager));
@@ -716,9 +752,47 @@ impl RustProxy {
listener.update_route_manager(Arc::clone(&new_manager));
// Cancel connections on routes that were removed or disabled
listener.invalidate_removed_routes(&active_route_ids);
// Clean up registry entries for removed routes
listener.connection_registry().cleanup_removed_routes(&active_route_ids);
// Prune HTTP proxy caches (rate limiters, regex cache, round-robin counters)
listener.prune_http_proxy_caches(&active_route_ids);
// Diff-based connection recycling for changed routes
{
let registry = listener.connection_registry();
for new_route in &routes {
let new_id = match &new_route.id {
Some(id) => id.as_str(),
None => continue,
};
// Find corresponding old route
let old_route = old_manager.routes().iter().find(|r| {
r.id.as_deref() == Some(new_id)
});
let old_route = match old_route {
Some(r) => r,
None => continue, // new route, no existing connections to recycle
};
// Security diff: re-evaluate existing connections' IPs
let old_sec = serde_json::to_string(&old_route.security).ok();
let new_sec = serde_json::to_string(&new_route.security).ok();
if old_sec != new_sec {
if let Some(ref security) = new_route.security {
registry.recycle_for_security_change(new_id, security);
}
// If security removed entirely (became more permissive), no recycling needed
}
// Action diff (targets, TLS mode, etc.): recycle all connections on route
let old_action = serde_json::to_string(&old_route.action).ok();
let new_action = serde_json::to_string(&new_route.action).ok();
if old_action != new_action {
registry.recycle_for_route_change(new_id);
}
}
}
// Add new ports
for port in &new_ports {
if !old_ports.contains(port) {
@@ -761,14 +835,22 @@ impl RustProxy {
if self.udp_listener_manager.is_none() {
if let Some(ref listener) = self.listener_manager {
let conn_tracker = listener.conn_tracker().clone();
let route_cancels = listener.route_cancels().clone();
let connection_registry = listener.connection_registry().clone();
let conn_config = Self::build_connection_config(&self.options);
let mut udp_mgr = UdpListenerManager::new(
Arc::clone(&new_manager),
Arc::clone(&self.metrics),
conn_tracker,
self.cancel_token.clone(),
route_cancels,
connection_registry,
);
udp_mgr.set_proxy_ips(conn_config.proxy_ips);
// Wire up H3ProxyService so QUIC connections can serve HTTP/3
let http_proxy = listener.http_proxy().clone();
let h3_svc = rustproxy_http::h3_service::H3ProxyService::new(http_proxy);
udp_mgr.set_h3_service(std::sync::Arc::new(h3_svc));
self.udp_listener_manager = Some(udp_mgr);
}
}
@@ -1070,6 +1152,10 @@ impl RustProxy {
}
/// Load a certificate for a domain and hot-swap the TLS configuration.
///
/// If the cert PEM differs from the currently loaded cert for this domain,
/// existing connections for the domain are gracefully recycled (GOAWAY for
/// HTTP/2, Connection: close for HTTP/1.1, graceful FIN for TCP).
pub async fn load_certificate(
&mut self,
domain: &str,
@@ -1079,6 +1165,12 @@ impl RustProxy {
) -> Result<()> {
info!("Loading certificate for domain: {}", domain);
// Check if the cert actually changed (for selective connection recycling)
let cert_changed = self.loaded_certs
.get(domain)
.map(|existing| existing.cert_pem != cert_pem)
.unwrap_or(false); // new domain = no existing connections to recycle
// Store in cert manager if available
if let Some(ref cm_arc) = self.cert_manager {
let now = std::time::SystemTime::now()
@@ -1127,6 +1219,13 @@ impl RustProxy {
}
}
// Recycle existing connections if cert actually changed
if cert_changed {
if let Some(ref listener) = self.listener_manager {
listener.connection_registry().recycle_for_cert_change(domain);
}
}
info!("Certificate loaded and TLS config updated for {}", domain);
Ok(())
}
+33 -36
View File
@@ -1,11 +1,5 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpsTerminateRoute,
createCompleteHttpsServer,
createHttpRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
mergeRouteConfigs,
cloneRoute,
@@ -19,8 +13,11 @@ import {
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route creation - createHttpsTerminateRoute produces correct structure', async () => {
const route = createHttpsTerminateRoute('secure.example.com', { host: '127.0.0.1', port: 8443 });
tap.test('route creation - HTTPS terminate route has correct structure', async () => {
const route: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8443 }], tls: { mode: 'terminate', certificate: 'auto' } }
};
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.action.type).toEqual('forward');
@@ -29,20 +26,10 @@ tap.test('route creation - createHttpsTerminateRoute produces correct structure'
expect(route.match.domains).toEqual('secure.example.com');
});
tap.test('route creation - createCompleteHttpsServer returns redirect and main route', async () => {
const routes = createCompleteHttpsServer('app.example.com', { host: '127.0.0.1', port: 3000 });
expect(routes).toBeArray();
expect(routes.length).toBeGreaterThanOrEqual(2);
// Should have an HTTP→HTTPS redirect and an HTTPS route
const hasRedirect = routes.some((r) => r.action.type === 'forward' && r.action.redirect !== undefined);
const hasHttps = routes.some((r) => r.action.tls?.mode === 'terminate');
expect(hasRedirect || hasHttps).toBeTrue();
});
tap.test('route validation - validateRoutes on a set of routes', async () => {
const routes: IRouteConfig[] = [
createHttpRoute('a.com', { host: '127.0.0.1', port: 3000 }),
createHttpRoute('b.com', { host: '127.0.0.1', port: 4000 }),
{ match: { ports: 80, domains: 'a.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
{ match: { ports: 80, domains: 'b.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] } },
];
const result = validateRoutes(routes);
expect(result.valid).toBeTrue();
@@ -51,7 +38,7 @@ tap.test('route validation - validateRoutes on a set of routes', async () => {
tap.test('route validation - validateRoutes catches invalid route in set', async () => {
const routes: any[] = [
createHttpRoute('valid.com', { host: '127.0.0.1', port: 3000 }),
{ match: { ports: 80, domains: 'valid.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
{ match: { ports: 80 } }, // missing action
];
const result = validateRoutes(routes);
@@ -60,23 +47,30 @@ tap.test('route validation - validateRoutes catches invalid route in set', async
});
tap.test('path matching - routeMatchesPath with exact path', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
route.match.path = '/api';
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com', path: '/api' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesPath(route, '/api')).toBeTrue();
expect(routeMatchesPath(route, '/other')).toBeFalse();
});
tap.test('path matching - route without path matches everything', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// No path set, should match any path
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesPath(route, '/anything')).toBeTrue();
expect(routeMatchesPath(route, '/')).toBeTrue();
});
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.priority = 10;
base.name = 'base-route';
const base: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
priority: 10,
name: 'base-route'
};
const merged = mergeRouteConfigs(base, {
priority: 50,
@@ -85,14 +79,16 @@ tap.test('route merging - mergeRouteConfigs combines routes', async () => {
expect(merged.priority).toEqual(50);
expect(merged.name).toEqual('merged-route');
// Original route fields should be preserved
expect(merged.match.domains).toEqual('example.com');
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
});
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.name = 'original';
const base: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
name: 'original'
};
const merged = mergeRouteConfigs(base, { name: 'changed' });
expect(base.name).toEqual('original');
@@ -100,20 +96,21 @@ tap.test('route merging - mergeRouteConfigs does not mutate original', async ()
});
tap.test('route cloning - cloneRoute produces independent copy', async () => {
const original = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
original.priority = 42;
original.name = 'original-route';
const original: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
priority: 42,
name: 'original-route'
};
const cloned = cloneRoute(original);
// Should be equal in value
expect(cloned.match.domains).toEqual('example.com');
expect(cloned.priority).toEqual(42);
expect(cloned.name).toEqual('original-route');
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
expect(cloned.action.targets![0].port).toEqual(3000);
// Should be independent - modifying clone shouldn't affect original
cloned.name = 'cloned-route';
cloned.priority = 99;
expect(original.name).toEqual('original-route');
+38 -34
View File
@@ -1,11 +1,5 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpRoute,
createHttpsTerminateRoute,
createLoadBalancerRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
findMatchingRoutes,
findBestMatchingRoute,
@@ -22,24 +16,11 @@ import {
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route creation - createHttpRoute produces correct structure', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.match.domains).toEqual('example.com');
expect(route.action.type).toEqual('forward');
expect(route.action.targets).toBeArray();
expect(route.action.targets![0].host).toEqual('127.0.0.1');
expect(route.action.targets![0].port).toEqual(3000);
});
tap.test('route creation - createHttpRoute with array of domains', async () => {
const route = createHttpRoute(['a.com', 'b.com'], { host: 'localhost', port: 8080 });
expect(route.match.domains).toEqual(['a.com', 'b.com']);
});
tap.test('route validation - validateRouteConfig accepts valid route', async () => {
const route = createHttpRoute('valid.example.com', { host: '10.0.0.1', port: 8080 });
const route: IRouteConfig = {
match: { ports: 80, domains: 'valid.example.com' },
action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 8080 }] }
};
const result = validateRouteConfig(route);
expect(result.valid).toBeTrue();
expect(result.errors).toHaveLength(0);
@@ -67,30 +48,44 @@ tap.test('route validation - isValidPort checks correctly', async () => {
});
tap.test('domain matching - exact domain', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesDomain(route, 'example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
});
tap.test('domain matching - wildcard domain', async () => {
const route = createHttpRoute('*.example.com', { host: '127.0.0.1', port: 3000 });
const route: IRouteConfig = {
match: { ports: 80, domains: '*.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesDomain(route, 'sub.example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
});
tap.test('port matching - single port', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// createHttpRoute defaults to port 80
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesPort(route, 80)).toBeTrue();
expect(routeMatchesPort(route, 443)).toBeFalse();
});
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
const lowPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
lowPriority.priority = 10;
const lowPriority: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
priority: 10
};
const highPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
highPriority.priority = 100;
const highPriority: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] },
priority: 100
};
const routes: IRouteConfig[] = [lowPriority, highPriority];
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
@@ -100,9 +95,18 @@ tap.test('route finding - findBestMatchingRoute selects by priority', async () =
});
tap.test('route finding - findMatchingRoutes returns all matches', async () => {
const route1 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
const route2 = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
const route3 = createHttpRoute('other.com', { host: '127.0.0.1', port: 5000 });
const route1: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] }
};
const route3: IRouteConfig = {
match: { ports: 80, domains: 'other.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 5000 }] }
};
const matches = findMatchingRoutes([route1, route2, route3], { domain: 'example.com', port: 80 });
expect(matches).toHaveLength(2);
+67 -114
View File
@@ -2,146 +2,101 @@ import * as path from 'path';
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute,
createApiRoute,
createWebSocketRoute
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
// Test to demonstrate various route configurations using the new helpers
tap.test('Route-based configuration examples', async (tools) => {
// Example 1: HTTP-only configuration
const httpOnlyRoute = createHttpRoute(
'http.example.com',
{
host: 'localhost',
port: 3000
},
{
name: 'Basic HTTP Route'
}
);
const httpOnlyRoute: IRouteConfig = {
match: { ports: 80, domains: 'http.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'Basic HTTP Route'
};
console.log('HTTP-only route created successfully:', httpOnlyRoute.name);
expect(httpOnlyRoute.action.type).toEqual('forward');
expect(httpOnlyRoute.match.domains).toEqual('http.example.com');
// Example 2: HTTPS Passthrough (SNI) configuration
const httpsPassthroughRoute = createHttpsPassthroughRoute(
'pass.example.com',
{
host: ['10.0.0.1', '10.0.0.2'], // Round-robin target IPs
port: 443
},
{
name: 'HTTPS Passthrough Route'
}
);
const httpsPassthroughRoute: IRouteConfig = {
match: { ports: 443, domains: 'pass.example.com' },
action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 443 }, { host: '10.0.0.2', port: 443 }], tls: { mode: 'passthrough' } },
name: 'HTTPS Passthrough Route'
};
expect(httpsPassthroughRoute).toBeTruthy();
expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough');
expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue();
// Example 3: HTTPS Termination to HTTP Backend
const terminateToHttpRoute = createHttpsTerminateRoute(
'secure.example.com',
{
host: 'localhost',
port: 8080
},
{
certificate: 'auto',
name: 'HTTPS Termination to HTTP Backend'
}
);
const terminateToHttpRoute: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'HTTPS Termination to HTTP Backend'
};
// Create the HTTP to HTTPS redirect for this domain
const httpToHttpsRedirect = createHttpToHttpsRedirect(
'secure.example.com',
443,
{
name: 'HTTP to HTTPS Redirect for secure.example.com'
}
);
const httpToHttpsRedirect: IRouteConfig = {
match: { ports: 80, domains: 'secure.example.com' },
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
name: 'HTTP to HTTPS Redirect for secure.example.com'
};
expect(terminateToHttpRoute).toBeTruthy();
expect(terminateToHttpRoute.action.tls?.mode).toEqual('terminate');
expect(httpToHttpsRedirect.action.type).toEqual('socket-handler');
// Example 4: Load Balancer with HTTPS
const loadBalancerRoute = createLoadBalancerRoute(
'proxy.example.com',
['internal-api-1.local', 'internal-api-2.local'],
8443,
{
tls: {
mode: 'terminate-and-reencrypt',
certificate: 'auto'
},
name: 'Load Balanced HTTPS Route'
}
);
const loadBalancerRoute: IRouteConfig = {
match: { ports: 443, domains: 'proxy.example.com' },
action: {
type: 'forward',
targets: [
{ host: 'internal-api-1.local', port: 8443 },
{ host: 'internal-api-2.local', port: 8443 }
],
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
},
name: 'Load Balanced HTTPS Route'
};
expect(loadBalancerRoute).toBeTruthy();
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue();
// Example 5: API Route
const apiRoute = createApiRoute(
'api.example.com',
'/api',
{ host: 'localhost', port: 8081 },
{
name: 'API Route',
useTls: true,
addCorsHeaders: true
}
);
const apiRoute: IRouteConfig = {
match: { ports: 443, domains: 'api.example.com', path: '/api' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8081 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
name: 'API Route'
};
expect(apiRoute.action.type).toEqual('forward');
expect(apiRoute.match.path).toBeTruthy();
// Example 6: Complete HTTPS Server with HTTP Redirect
const httpsServerRoutes = createCompleteHttpsServer(
'complete.example.com',
{
host: 'localhost',
port: 8080
const httpsRoute: IRouteConfig = {
match: { ports: 443, domains: 'complete.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'Complete HTTPS Server'
};
const httpsRedirectRoute: IRouteConfig = {
match: { ports: 80, domains: 'complete.example.com' },
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
name: 'Complete HTTPS Server - Redirect'
};
const webSocketRoute: IRouteConfig = {
match: { ports: 443, domains: 'ws.example.com', path: '/ws' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8082 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: { enabled: true }
},
{
certificate: 'auto',
name: 'Complete HTTPS Server'
}
);
expect(Array.isArray(httpsServerRoutes)).toBeTrue();
expect(httpsServerRoutes.length).toEqual(2); // HTTPS route and HTTP redirect
expect(httpsServerRoutes[0].action.tls?.mode).toEqual('terminate');
expect(httpsServerRoutes[1].action.type).toEqual('socket-handler');
// Example 7: Static File Server - removed (use nginx/apache behind proxy)
// Example 8: WebSocket Route
const webSocketRoute = createWebSocketRoute(
'ws.example.com',
'/ws',
{ host: 'localhost', port: 8082 },
{
useTls: true,
name: 'WebSocket Route'
}
);
name: 'WebSocket Route'
};
expect(webSocketRoute.action.type).toEqual('forward');
expect(webSocketRoute.action.websocket?.enabled).toBeTrue();
// Create a SmartProxy instance with all routes
const allRoutes: IRouteConfig[] = [
httpOnlyRoute,
httpsPassthroughRoute,
@@ -149,19 +104,17 @@ tap.test('Route-based configuration examples', async (tools) => {
httpToHttpsRedirect,
loadBalancerRoute,
apiRoute,
...httpsServerRoutes,
httpsRoute,
httpsRedirectRoute,
webSocketRoute
];
// We're not actually starting the SmartProxy in this test,
// just verifying that the configuration is valid
const smartProxy = new SmartProxy({
routes: allRoutes
});
// Just verify that all routes are configured correctly
console.log(`Created ${allRoutes.length} example routes`);
expect(allRoutes.length).toEqual(9); // One less without static file route
expect(allRoutes.length).toEqual(9);
});
export default tap.start();
export default tap.start();
+18 -48
View File
@@ -1,27 +1,8 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
// Import route-based helpers
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
// Create helper functions for backward compatibility
const helpers = {
httpOnly: (domains: string | string[], target: any) => createHttpRoute(domains, target),
tlsTerminateToHttp: (domains: string | string[], target: any) =>
createHttpsTerminateRoute(domains, target),
tlsTerminateToHttps: (domains: string | string[], target: any) =>
createHttpsTerminateRoute(domains, target, { reencrypt: true }),
httpsPassthrough: (domains: string | string[], target: any) =>
createHttpsPassthroughRoute(domains, target)
};
// Route-based utility functions for testing
function findRouteForDomain(routes: any[], domain: string): any {
return routes.find(route => {
const domains = Array.isArray(route.match.domains)
@@ -31,55 +12,44 @@ function findRouteForDomain(routes: any[], domain: string): any {
});
}
// Replace the old test with route-based tests
tap.test('Route Helpers - Create HTTP routes', async () => {
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] }
};
expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('example.com');
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
});
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {
const route = helpers.tlsTerminateToHttp('secure.example.com', { host: 'localhost', port: 3000 });
const route: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } }
};
expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('secure.example.com');
expect(route.action.tls?.mode).toEqual('terminate');
});
tap.test('Route Helpers - Create HTTPS passthrough routes', async () => {
const route = helpers.httpsPassthrough('passthrough.example.com', { host: 'backend', port: 443 });
const route: IRouteConfig = {
match: { ports: 443, domains: 'passthrough.example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'passthrough' } }
};
expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('passthrough.example.com');
expect(route.action.tls?.mode).toEqual('passthrough');
});
tap.test('Route Helpers - Create HTTPS to HTTPS routes', async () => {
const route = helpers.tlsTerminateToHttps('reencrypt.example.com', { host: 'backend', port: 443 });
const route: IRouteConfig = {
match: { ports: 443, domains: 'reencrypt.example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' } }
};
expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('reencrypt.example.com');
expect(route.action.tls?.mode).toEqual('terminate-and-reencrypt');
});
tap.test('Route Helpers - Create complete HTTPS server with redirect', async () => {
const routes = createCompleteHttpsServer(
'full.example.com',
{ host: 'localhost', port: 3000 },
{ certificate: 'auto' }
);
expect(routes.length).toEqual(2);
// Check HTTP to HTTPS redirect - find route by port
const redirectRoute = routes.find(r => r.match.ports === 80);
expect(redirectRoute.action.type).toEqual('socket-handler');
expect(redirectRoute.action.socketHandler).toBeDefined();
expect(redirectRoute.match.ports).toEqual(80);
// Check HTTPS route
const httpsRoute = routes.find(r => r.action.type === 'forward');
expect(httpsRoute.match.ports).toEqual(443);
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
});
// Export test runner
export default tap.start();
export default tap.start();
+45 -45
View File
@@ -1,15 +1,14 @@
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as child_process from 'child_process';
import { promisify } from 'util';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
const exec = promisify(child_process.exec);
// Check if we have root privileges to run NFTables tests
async function checkRootPrivileges(): Promise<boolean> {
try {
// Check if we're running as root
const { stdout } = await exec('id -u');
return stdout.trim() === '0';
} catch (err) {
@@ -17,7 +16,6 @@ async function checkRootPrivileges(): Promise<boolean> {
}
}
// Check if tests should run
const isRoot = await checkRootPrivileges();
if (!isRoot) {
@@ -29,68 +27,70 @@ if (!isRoot) {
console.log('');
}
// Define the test with proper skip condition
const testFn = isRoot ? tap.test : tap.skip.test;
testFn('NFTables integration tests', async () => {
console.log('Running NFTables tests with root privileges');
// Create test routes
const routes = [
createNfTablesRoute('tcp-forward', {
host: 'localhost',
port: 8080
}, {
ports: 9080,
protocol: 'tcp'
}),
createNfTablesRoute('udp-forward', {
host: 'localhost',
port: 5353
}, {
ports: 5354,
protocol: 'udp'
}),
createNfTablesRoute('port-range', {
host: 'localhost',
port: 8080
}, {
ports: [{ from: 9000, to: 9100 }],
protocol: 'tcp'
})
const routes: IRouteConfig[] = [
{
match: { ports: 9080 },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: 8080 }],
nftables: { protocol: 'tcp' }
},
name: 'tcp-forward'
},
{
match: { ports: 5354 },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: 5353 }],
nftables: { protocol: 'udp' }
},
name: 'udp-forward'
},
{
match: { ports: [{ from: 9000, to: 9100 }] },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: 8080 }],
nftables: { protocol: 'tcp' }
},
name: 'port-range'
}
];
const smartProxy = new SmartProxy({
enableDetailedLogging: true,
routes
});
// Start the proxy
await smartProxy.start();
console.log('SmartProxy started with NFTables routes');
// Get NFTables status
const status = await smartProxy.getNfTablesStatus();
console.log('NFTables status:', JSON.stringify(status, null, 2));
// Verify all routes are provisioned
expect(Object.keys(status).length).toEqual(routes.length);
for (const routeStatus of Object.values(status)) {
expect(routeStatus.active).toBeTrue();
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
}
// Stop the proxy
await smartProxy.stop();
console.log('SmartProxy stopped');
// Verify all rules are cleaned up
const finalStatus = await smartProxy.getNfTablesStatus();
expect(Object.keys(finalStatus).length).toEqual(0);
});
export default tap.start();
export default tap.start();
+89 -102
View File
@@ -1,5 +1,4 @@
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import * as http from 'http';
@@ -10,13 +9,13 @@ import { fileURLToPath } from 'url';
import * as child_process from 'child_process';
import { promisify } from 'util';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
const exec = promisify(child_process.exec);
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Check if we have root privileges
async function checkRootPrivileges(): Promise<boolean> {
try {
const { stdout } = await exec('id -u');
@@ -26,7 +25,6 @@ async function checkRootPrivileges(): Promise<boolean> {
}
}
// Check if tests should run
const runTests = await checkRootPrivileges();
if (!runTests) {
@@ -36,10 +34,8 @@ if (!runTests) {
console.log('Skipping NFTables integration tests');
console.log('========================================');
console.log('');
// Skip tests when not running as root - tests are marked with tap.skip.test
}
// Test server and client utilities
let testTcpServer: net.Server;
let testHttpServer: http.Server;
let testHttpsServer: https.Server;
@@ -53,10 +49,8 @@ const PROXY_HTTP_PORT = 5001;
const PROXY_HTTPS_PORT = 5002;
const TEST_DATA = 'Hello through NFTables!';
// Helper to create test certificates
async function createTestCertificates() {
try {
// Import the certificate helper
const certsModule = await import('./helpers/certificates.js');
const certificates = certsModule.loadTestCertificates();
return {
@@ -65,7 +59,6 @@ async function createTestCertificates() {
};
} catch (err) {
console.error('Failed to load test certificates:', err);
// Use dummy certificates for testing
return {
cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'),
key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8')
@@ -75,111 +68,112 @@ async function createTestCertificates() {
tap.skip.test('setup NFTables integration test environment', async () => {
console.log('Running NFTables integration tests with root privileges');
// Create a basic TCP test server
testTcpServer = net.createServer((socket) => {
socket.on('data', (data) => {
socket.write(`Server says: ${data.toString()}`);
});
});
await new Promise<void>((resolve) => {
testTcpServer.listen(TEST_TCP_PORT, () => {
console.log(`TCP test server listening on port ${TEST_TCP_PORT}`);
resolve();
});
});
// Create an HTTP test server
testHttpServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`HTTP Server says: ${TEST_DATA}`);
});
await new Promise<void>((resolve) => {
testHttpServer.listen(TEST_HTTP_PORT, () => {
console.log(`HTTP test server listening on port ${TEST_HTTP_PORT}`);
resolve();
});
});
// Create an HTTPS test server
const certs = await createTestCertificates();
testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`HTTPS Server says: ${TEST_DATA}`);
});
await new Promise<void>((resolve) => {
testHttpsServer.listen(TEST_HTTPS_PORT, () => {
console.log(`HTTPS test server listening on port ${TEST_HTTPS_PORT}`);
resolve();
});
});
// Create SmartProxy with various NFTables routes
smartProxy = new SmartProxy({
enableDetailedLogging: true,
routes: [
// TCP forwarding route
createNfTablesRoute('tcp-nftables', {
host: 'localhost',
port: TEST_TCP_PORT
}, {
ports: PROXY_TCP_PORT,
protocol: 'tcp'
}),
// HTTP forwarding route
createNfTablesRoute('http-nftables', {
host: 'localhost',
port: TEST_HTTP_PORT
}, {
ports: PROXY_HTTP_PORT,
protocol: 'tcp'
}),
// HTTPS termination route
createNfTablesTerminateRoute('https-nftables.example.com', {
host: 'localhost',
port: TEST_HTTPS_PORT
}, {
ports: PROXY_HTTPS_PORT,
protocol: 'tcp',
certificate: certs
}),
// Route with IP allow list
createNfTablesRoute('secure-tcp', {
host: 'localhost',
port: TEST_TCP_PORT
}, {
ports: 5003,
protocol: 'tcp',
ipAllowList: ['127.0.0.1', '::1']
}),
// Route with QoS settings
createNfTablesRoute('qos-tcp', {
host: 'localhost',
port: TEST_TCP_PORT
}, {
ports: 5004,
protocol: 'tcp',
maxRate: '10mbps',
priority: 1
})
{
match: { ports: PROXY_TCP_PORT },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
nftables: { protocol: 'tcp' }
},
name: 'tcp-nftables'
},
{
match: { ports: PROXY_HTTP_PORT },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: TEST_HTTP_PORT }],
nftables: { protocol: 'tcp' }
},
name: 'http-nftables'
},
{
match: { ports: PROXY_HTTPS_PORT, domains: 'https-nftables.example.com' },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: TEST_HTTPS_PORT }],
tls: { mode: 'terminate', certificate: 'auto' },
nftables: { protocol: 'tcp' }
},
name: 'https-nftables'
},
{
match: { ports: 5003 },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
nftables: { protocol: 'tcp', ipAllowList: ['127.0.0.1', '::1'] }
},
name: 'secure-tcp'
},
{
match: { ports: 5004 },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
nftables: { protocol: 'tcp', maxRate: '10mbps', priority: 1 }
},
name: 'qos-tcp'
}
]
});
console.log('SmartProxy created, now starting...');
// Start the proxy
try {
await smartProxy.start();
console.log('SmartProxy started successfully');
// Verify proxy is listening on expected ports
const listeningPorts = smartProxy.getListeningPorts();
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
} catch (err) {
@@ -190,8 +184,7 @@ tap.skip.test('setup NFTables integration test environment', async () => {
tap.skip.test('should forward TCP connections through NFTables', async () => {
console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`);
// First verify our test server is running
try {
const testClient = new net.Socket();
await new Promise<void>((resolve, reject) => {
@@ -205,40 +198,39 @@ tap.skip.test('should forward TCP connections through NFTables', async () => {
} catch (err) {
console.error(`Test server on port ${TEST_TCP_PORT} is not accessible: ${err}`);
}
// Connect to the proxy port
const client = new net.Socket();
const response = await new Promise<string>((resolve, reject) => {
let responseData = '';
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`Connection timeout after 5 seconds to proxy port ${PROXY_TCP_PORT}`));
}, 5000);
client.connect(PROXY_TCP_PORT, 'localhost', () => {
console.log(`Connected to proxy port ${PROXY_TCP_PORT}, sending data...`);
client.write(TEST_DATA);
});
client.on('data', (data) => {
console.log(`Received data from proxy: ${data.toString()}`);
responseData += data.toString();
client.end();
});
client.on('end', () => {
clearTimeout(timeout);
resolve(responseData);
});
client.on('error', (err) => {
clearTimeout(timeout);
console.error(`Connection error on proxy port ${PROXY_TCP_PORT}: ${err.message}`);
reject(err);
});
});
expect(response).toEqual(`Server says: ${TEST_DATA}`);
});
@@ -254,21 +246,20 @@ tap.skip.test('should forward HTTP connections through NFTables', async () => {
});
}).on('error', reject);
});
expect(response).toEqual(`HTTP Server says: ${TEST_DATA}`);
});
tap.skip.test('should handle HTTPS termination with NFTables', async () => {
// Skip this test if running without proper certificates
const response = await new Promise<string>((resolve, reject) => {
const options = {
hostname: 'localhost',
port: PROXY_HTTPS_PORT,
path: '/',
method: 'GET',
rejectUnauthorized: false // For self-signed cert
rejectUnauthorized: false
};
https.get(options, (res) => {
let data = '';
res.on('data', (chunk) => {
@@ -279,43 +270,40 @@ tap.skip.test('should handle HTTPS termination with NFTables', async () => {
});
}).on('error', reject);
});
expect(response).toEqual(`HTTPS Server says: ${TEST_DATA}`);
});
tap.skip.test('should respect IP allow lists in NFTables', async () => {
// This test should pass since we're connecting from localhost
const client = new net.Socket();
const connected = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
client.destroy();
resolve(false);
}, 2000);
client.connect(5003, 'localhost', () => {
clearTimeout(timeout);
client.end();
resolve(true);
});
client.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
expect(connected).toBeTrue();
});
tap.skip.test('should get NFTables status', async () => {
const status = await smartProxy.getNfTablesStatus();
// Check that we have status for our routes
const statusKeys = Object.keys(status);
expect(statusKeys.length).toBeGreaterThan(0);
// Check status structure for one of the routes
const firstStatus = status[statusKeys[0]];
expect(firstStatus).toHaveProperty('active');
expect(firstStatus).toHaveProperty('ruleCount');
@@ -324,21 +312,20 @@ tap.skip.test('should get NFTables status', async () => {
});
tap.skip.test('cleanup NFTables integration test environment', async () => {
// Stop the proxy and test servers
await smartProxy.stop();
await new Promise<void>((resolve) => {
testTcpServer.close(() => {
resolve();
});
});
await new Promise<void>((resolve) => {
testHttpServer.close(() => {
resolve();
});
});
await new Promise<void>((resolve) => {
testHttpsServer.close(() => {
resolve();
@@ -346,4 +333,4 @@ tap.skip.test('cleanup NFTables integration test environment', async () => {
});
});
export default tap.start();
export default tap.start();
+75 -111
View File
@@ -1,30 +1,20 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import {
createPortMappingRoute,
createOffsetPortMappingRoute,
createDynamicRoute,
createSmartLoadBalancer,
createPortOffset
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
// Test server and client utilities
let testServers: Array<{ server: net.Server; port: number }> = [];
let smartProxy: SmartProxy;
let TEST_PORTS: number[]; // 3 test server ports
let PROXY_PORTS: number[]; // 6 proxy ports
let TEST_PORTS: number[];
let PROXY_PORTS: number[];
const TEST_DATA = 'Hello through dynamic port mapper!';
// Cleanup function to close all servers and proxies
function cleanup() {
console.log('Starting cleanup...');
const promises = [];
// Close test servers
for (const { server, port } of testServers) {
promises.push(new Promise<void>(resolve => {
console.log(`Closing test server on port ${port}`);
@@ -34,31 +24,28 @@ function cleanup() {
});
}));
}
// Stop SmartProxy
if (smartProxy) {
console.log('Stopping SmartProxy...');
promises.push(smartProxy.stop().then(() => {
console.log('SmartProxy stopped');
}));
}
return Promise.all(promises);
}
// Helper: Creates a test TCP server that listens on a given port
function createTestServer(port: number): Promise<net.Server> {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
socket.on('data', (data) => {
// Echo the received data back with a server identifier
socket.write(`Server ${port} says: ${data.toString()}`);
});
socket.on('error', (error) => {
console.error(`[Test Server] Socket error on port ${port}:`, error);
});
});
server.listen(port, () => {
console.log(`[Test Server] Listening on port ${port}`);
testServers.push({ server, port });
@@ -67,32 +54,31 @@ function createTestServer(port: number): Promise<net.Server> {
});
}
// Helper: Creates a test client connection with timeout
function createTestClient(port: number, data: string): Promise<string> {
return new Promise((resolve, reject) => {
const client = new net.Socket();
let response = '';
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`Client connection timeout to port ${port}`));
}, 5000);
client.connect(port, 'localhost', () => {
console.log(`[Test Client] Connected to server on port ${port}`);
client.write(data);
});
client.on('data', (chunk) => {
response += chunk.toString();
client.end();
});
client.on('end', () => {
clearTimeout(timeout);
resolve(response);
});
client.on('error', (error) => {
clearTimeout(timeout);
reject(error);
@@ -100,123 +86,108 @@ function createTestClient(port: number, data: string): Promise<string> {
});
}
// Set up test environment
tap.test('setup port mapping test environment', async () => {
const allPorts = await findFreePorts(9);
TEST_PORTS = allPorts.slice(0, 3);
PROXY_PORTS = allPorts.slice(3, 9);
// Create multiple test servers on different ports
await Promise.all([
createTestServer(TEST_PORTS[0]),
createTestServer(TEST_PORTS[1]),
createTestServer(TEST_PORTS[2]),
]);
// Compute dynamic offset between proxy and test ports
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
// Create a SmartProxy with dynamic port mapping routes
smartProxy = new SmartProxy({
routes: [
// Simple function that returns the same port (identity mapping)
createPortMappingRoute({
sourcePortRange: PROXY_PORTS[0],
targetHost: 'localhost',
portMapper: (context) => TEST_PORTS[0],
name: 'Identity Port Mapping'
}),
// Offset port mapping using dynamic offset
createOffsetPortMappingRoute({
ports: PROXY_PORTS[1],
targetHost: 'localhost',
offset: portOffset,
name: `Offset Port Mapping (${portOffset})`
}),
// Dynamic route with conditional port mapping
createDynamicRoute({
ports: [PROXY_PORTS[2], PROXY_PORTS[3]],
targetHost: (context) => {
// Dynamic host selection based on port
return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1';
{
match: { ports: PROXY_PORTS[0] },
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: (context: IRouteContext) => TEST_PORTS[0]
}]
},
portMapper: (context) => {
// Port mapping logic based on incoming port
if (context.port === PROXY_PORTS[2]) {
return TEST_PORTS[0];
} else {
return TEST_PORTS[2];
}
name: 'Identity Port Mapping'
},
{
match: { ports: PROXY_PORTS[1] },
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: (context: IRouteContext) => context.port + portOffset
}]
},
name: `Offset Port Mapping (${portOffset})`
},
{
match: { ports: [PROXY_PORTS[2], PROXY_PORTS[3]] },
action: {
type: 'forward',
targets: [{
host: (context: IRouteContext) => {
return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1';
},
port: (context: IRouteContext) => {
if (context.port === PROXY_PORTS[2]) {
return TEST_PORTS[0];
} else {
return TEST_PORTS[2];
}
}
}]
},
name: 'Dynamic Host and Port Mapping'
}),
},
// Smart load balancer for domain-based routing
createSmartLoadBalancer({
ports: PROXY_PORTS[4],
domainTargets: {
'test1.example.com': 'localhost',
'test2.example.com': '127.0.0.1'
{
match: { ports: PROXY_PORTS[4] },
action: {
type: 'forward',
targets: [{
host: (context: IRouteContext) => {
if (context.domain === 'test1.example.com') return 'localhost';
if (context.domain === 'test2.example.com') return '127.0.0.1';
return 'localhost';
},
port: (context: IRouteContext) => {
if (context.domain === 'test1.example.com') {
return TEST_PORTS[0];
} else {
return TEST_PORTS[1];
}
}
}]
},
portMapper: (context) => {
// Use different backend ports based on domain
if (context.domain === 'test1.example.com') {
return TEST_PORTS[0];
} else {
return TEST_PORTS[1];
}
},
defaultTarget: 'localhost',
name: 'Smart Domain Load Balancer'
})
}
]
});
// Start the SmartProxy
await smartProxy.start();
});
// Test 1: Simple identity port mapping
tap.test('should map port using identity function', async () => {
const response = await createTestClient(PROXY_PORTS[0], TEST_DATA);
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
});
// Test 2: Offset port mapping
tap.test('should map port using offset function', async () => {
const response = await createTestClient(PROXY_PORTS[1], TEST_DATA);
expect(response).toEqual(`Server ${TEST_PORTS[1]} says: ${TEST_DATA}`);
});
// Test 3: Dynamic port and host mapping (conditional logic)
tap.test('should map port using dynamic logic', async () => {
const response = await createTestClient(PROXY_PORTS[2], TEST_DATA);
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
});
// Test 4: Test reuse of createPortOffset helper
tap.test('should use createPortOffset helper for port mapping', async () => {
// Test the createPortOffset helper with dynamic offset
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
const offsetFn = createPortOffset(portOffset);
const context = {
port: PROXY_PORTS[1],
clientIp: '127.0.0.1',
serverIp: '127.0.0.1',
isTls: false,
timestamp: Date.now(),
connectionId: 'test-connection'
} as IRouteContext;
const mappedPort = offsetFn(context);
expect(mappedPort).toEqual(TEST_PORTS[1]);
});
// Test 5: Test error handling for invalid port mapping functions
tap.test('should handle errors in port mapping functions', async () => {
// Create a route with a function that throws an error
const errorRoute: IRouteConfig = {
match: {
ports: PROXY_PORTS[5]
@@ -232,34 +203,27 @@ tap.test('should handle errors in port mapping functions', async () => {
},
name: 'Error Route'
};
// Add the route to SmartProxy
await smartProxy.updateRoutes([...smartProxy.settings.routes, errorRoute]);
// The connection should fail or timeout
try {
await createTestClient(PROXY_PORTS[5], TEST_DATA);
// Connection should not succeed
expect(false).toBeTrue();
} catch (error) {
// Connection failed as expected
expect(true).toBeTrue();
}
});
// Cleanup
tap.test('cleanup port mapping test environment', async () => {
// Add timeout to prevent hanging if SmartProxy shutdown has issues
const cleanupPromise = cleanup();
const timeoutPromise = new Promise((_, reject) =>
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Cleanup timeout after 5 seconds')), 5000)
);
try {
await Promise.race([cleanupPromise, timeoutPromise]);
} catch (error) {
console.error('Cleanup error:', error);
// Force cleanup even if there's an error
testServers = [];
smartProxy = null as any;
}
@@ -267,4 +231,4 @@ tap.test('cleanup port mapping test environment', async () => {
await assertPortsFree([...TEST_PORTS, ...PROXY_PORTS]);
});
export default tap.start();
export default tap.start();
+266 -223
View File
@@ -6,7 +6,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
// Import from core modules
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
// Import route utilities and helpers
// Import route utilities
import {
findMatchingRoutes,
findBestMatchingRoute,
@@ -28,16 +28,7 @@ import {
assertValidRoute
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute,
createApiRoute,
createWebSocketRoute
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
// Import test helpers
import { loadTestCertificates } from './helpers/certificates.js';
@@ -47,12 +38,12 @@ import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.
// --------------------------------- Route Creation Tests ---------------------------------
tap.test('Routes: Should create basic HTTP route', async () => {
// Create a simple HTTP route
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }, {
const httpRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'Basic HTTP Route'
});
};
// Validate the route configuration
expect(httpRoute.match.ports).toEqual(80);
expect(httpRoute.match.domains).toEqual('example.com');
expect(httpRoute.action.type).toEqual('forward');
@@ -62,14 +53,17 @@ tap.test('Routes: Should create basic HTTP route', async () => {
});
tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
// Create an HTTPS route with TLS termination
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8080 }, {
certificate: 'auto',
const httpsRoute: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
name: 'HTTPS Route'
});
};
// Validate the route configuration
expect(httpsRoute.match.ports).toEqual(443); // Default HTTPS port
expect(httpsRoute.match.ports).toEqual(443);
expect(httpsRoute.match.domains).toEqual('secure.example.com');
expect(httpsRoute.action.type).toEqual('forward');
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
@@ -80,10 +74,15 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
});
tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
// Create an HTTP to HTTPS redirect
const redirectRoute = createHttpToHttpsRedirect('example.com', 443);
const redirectRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
},
name: 'HTTP to HTTPS Redirect for example.com'
};
// Validate the route configuration
expect(redirectRoute.match.ports).toEqual(80);
expect(redirectRoute.match.domains).toEqual('example.com');
expect(redirectRoute.action.type).toEqual('socket-handler');
@@ -91,22 +90,34 @@ tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
});
tap.test('Routes: Should create complete HTTPS server with redirects', async () => {
// Create a complete HTTPS server setup
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 8080 }, {
certificate: 'auto'
});
const routes: IRouteConfig[] = [
{
match: { ports: 443, domains: 'example.com' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
name: 'HTTPS Terminate Route for example.com'
},
{
match: { ports: 80, domains: 'example.com' },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
},
name: 'HTTP to HTTPS Redirect for example.com'
}
];
// Validate that we got two routes (HTTPS route and HTTP redirect)
expect(routes.length).toEqual(2);
// Validate HTTPS route
const httpsRoute = routes[0];
expect(httpsRoute.match.ports).toEqual(443);
expect(httpsRoute.match.domains).toEqual('example.com');
expect(httpsRoute.action.type).toEqual('forward');
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
// Validate HTTP redirect route
const redirectRoute = routes[1];
expect(redirectRoute.match.ports).toEqual(80);
expect(redirectRoute.action.type).toEqual('socket-handler');
@@ -114,21 +125,17 @@ tap.test('Routes: Should create complete HTTPS server with redirects', async ()
});
tap.test('Routes: Should create load balancer route', async () => {
// Create a load balancer route
const lbRoute = createLoadBalancerRoute(
'app.example.com',
['10.0.0.1', '10.0.0.2', '10.0.0.3'],
8080,
{
tls: {
mode: 'terminate',
certificate: 'auto'
},
name: 'Load Balanced Route'
}
);
const lbRoute: IRouteConfig = {
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
loadBalancing: { algorithm: 'round-robin' }
},
name: 'Load Balanced Route'
};
// Validate the route configuration
expect(lbRoute.match.domains).toEqual('app.example.com');
expect(lbRoute.action.type).toEqual('forward');
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
@@ -139,23 +146,32 @@ tap.test('Routes: Should create load balancer route', async () => {
});
tap.test('Routes: Should create API route with CORS', async () => {
// Create an API route with CORS headers
const apiRoute = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
useTls: true,
certificate: 'auto',
addCorsHeaders: true,
const apiRoute: IRouteConfig = {
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
headers: {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
},
priority: 100,
name: 'API Route'
});
};
// Validate the route configuration
expect(apiRoute.match.domains).toEqual('api.example.com');
expect(apiRoute.match.path).toEqual('/v1/*');
expect(apiRoute.action.type).toEqual('forward');
expect(apiRoute.action.tls?.mode).toEqual('terminate');
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
// Check CORS headers
expect(apiRoute.headers).toBeDefined();
if (apiRoute.headers?.response) {
expect(apiRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
@@ -164,23 +180,25 @@ tap.test('Routes: Should create API route with CORS', async () => {
});
tap.test('Routes: Should create WebSocket route', async () => {
// Create a WebSocket route
const wsRoute = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 5000 }, {
useTls: true,
certificate: 'auto',
pingInterval: 15000,
const wsRoute: IRouteConfig = {
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 5000 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: { enabled: true, pingInterval: 15000 }
},
priority: 100,
name: 'WebSocket Route'
});
};
// Validate the route configuration
expect(wsRoute.match.domains).toEqual('ws.example.com');
expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.tls?.mode).toEqual('terminate');
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
// Check WebSocket configuration
expect(wsRoute.action.websocket).toBeDefined();
if (wsRoute.action.websocket) {
expect(wsRoute.action.websocket.enabled).toBeTrue();
@@ -191,22 +209,27 @@ tap.test('Routes: Should create WebSocket route', async () => {
// Static file serving has been removed - should be handled by external servers
tap.test('SmartProxy: Should create instance with route-based config', async () => {
// Create TLS certificates for testing
const certs = loadTestCertificates();
// Create a SmartProxy instance with route-based configuration
const proxy = new SmartProxy({
routes: [
createHttpRoute('example.com', { host: 'localhost', port: 3000 }, {
{
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route'
}),
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8443 }, {
certificate: {
key: certs.privateKey,
cert: certs.publicKey
},
{
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8443 }],
tls: {
mode: 'terminate',
certificate: { key: certs.privateKey, cert: certs.publicKey }
}
},
name: 'HTTPS Route'
})
}
],
defaults: {
target: {
@@ -218,13 +241,11 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
maxConnections: 100
}
},
// Additional settings
initialDataTimeout: 10000,
inactivityTimeout: 300000,
enableDetailedLogging: true
});
// Simply verify the instance was created successfully
expect(typeof proxy).toEqual('object');
expect(typeof proxy.start).toEqual('function');
expect(typeof proxy.stop).toEqual('function');
@@ -233,94 +254,109 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
// --------------------------------- Edge Case Tests ---------------------------------
tap.test('Edge Case - Empty Routes Array', async () => {
// Attempting to find routes in an empty array
const emptyRoutes: IRouteConfig[] = [];
const matches = findMatchingRoutes(emptyRoutes, { domain: 'example.com', port: 80 });
expect(matches).toBeInstanceOf(Array);
expect(matches.length).toEqual(0);
const bestMatch = findBestMatchingRoute(emptyRoutes, { domain: 'example.com', port: 80 });
expect(bestMatch).toBeUndefined();
});
tap.test('Edge Case - Multiple Matching Routes with Same Priority', async () => {
// Create multiple routes with identical priority but different targets
const route1 = createHttpRoute('example.com', { host: 'server1', port: 3000 });
const route2 = createHttpRoute('example.com', { host: 'server2', port: 3000 });
const route3 = createHttpRoute('example.com', { host: 'server3', port: 3000 });
// Set all to the same priority
const route1: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
name: 'HTTP Route for example.com'
};
const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server2', port: 3000 }] },
name: 'HTTP Route for example.com'
};
const route3: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server3', port: 3000 }] },
name: 'HTTP Route for example.com'
};
route1.priority = 100;
route2.priority = 100;
route3.priority = 100;
const routes = [route1, route2, route3];
// Find matching routes
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
// Should find all three routes
expect(matches.length).toEqual(3);
// First match could be any of the routes since they have the same priority
// But the implementation should be consistent (likely keep the original order)
const bestMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(bestMatch).not.toBeUndefined();
});
tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
// Create routes with wildcard domains and path patterns
const wildcardApiRoute = createApiRoute('*.example.com', '/api', { host: 'api-server', port: 3000 }, {
useTls: true,
certificate: 'auto'
});
const exactApiRoute = createApiRoute('api.example.com', '/api', { host: 'specific-api-server', port: 3001 }, {
useTls: true,
certificate: 'auto',
priority: 200 // Higher priority
});
const wildcardApiRoute: IRouteConfig = {
match: { ports: 443, domains: '*.example.com', path: '/api/*' },
action: {
type: 'forward',
targets: [{ host: 'api-server', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
priority: 100,
name: 'API Route for *.example.com'
};
const exactApiRoute: IRouteConfig = {
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
action: {
type: 'forward',
targets: [{ host: 'specific-api-server', port: 3001 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
priority: 200,
name: 'API Route for api.example.com'
};
const routes = [wildcardApiRoute, exactApiRoute];
// Test with a specific subdomain that matches both routes
const matches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
// Should match both routes
expect(matches.length).toEqual(2);
// The exact domain match should have higher priority
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
expect(bestMatch).not.toBeUndefined();
if (bestMatch) {
expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route
expect(bestMatch.action.targets[0].port).toEqual(3001);
}
// Test with a different subdomain - should only match the wildcard route
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
expect(otherMatches.length).toEqual(1);
expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route
expect(otherMatches[0].action.targets[0].port).toEqual(3000);
});
tap.test('Edge Case - Disabled Routes', async () => {
// Create enabled and disabled routes
const enabledRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
const disabledRoute = createHttpRoute('example.com', { host: 'server2', port: 3001 });
const enabledRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
name: 'HTTP Route for example.com'
};
const disabledRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
name: 'HTTP Route for example.com'
};
disabledRoute.enabled = false;
const routes = [enabledRoute, disabledRoute];
// Find matching routes
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
// Should only find the enabled route
expect(matches.length).toEqual(1);
expect(matches[0].action.targets[0].port).toEqual(3000);
});
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
// Create route with complex path and headers matching
const complexRoute: IRouteConfig = {
match: {
domains: 'api.example.com',
@@ -344,22 +380,20 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
},
name: 'Complex API Route'
};
// Test with matching criteria
const matchingPath = routeMatchesPath(complexRoute, '/api/v2/users');
expect(matchingPath).toBeTrue();
const matchingHeaders = routeMatchesHeaders(complexRoute, {
'Content-Type': 'application/json',
'X-API-Key': 'valid-key',
'Accept': 'application/json'
});
expect(matchingHeaders).toBeTrue();
// Test with non-matching criteria
const nonMatchingPath = routeMatchesPath(complexRoute, '/api/v1/users');
expect(nonMatchingPath).toBeFalse();
const nonMatchingHeaders = routeMatchesHeaders(complexRoute, {
'Content-Type': 'application/json',
'X-API-Key': 'invalid-key'
@@ -368,7 +402,6 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
});
tap.test('Edge Case - Port Range Matching', async () => {
// Create route with port range matching
const portRangeRoute: IRouteConfig = {
match: {
domains: 'example.com',
@@ -383,17 +416,14 @@ tap.test('Edge Case - Port Range Matching', async () => {
},
name: 'Port Range Route'
};
// Test with ports in the range
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue(); // Lower bound
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue(); // Middle
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue(); // Upper bound
// Test with ports outside the range
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse(); // Just below
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse(); // Just above
// Test with multiple port ranges
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse();
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse();
const multiRangeRoute: IRouteConfig = {
match: {
domains: 'example.com',
@@ -411,7 +441,7 @@ tap.test('Edge Case - Port Range Matching', async () => {
},
name: 'Multi Range Route'
};
expect(routeMatchesPort(multiRangeRoute, 85)).toBeTrue();
expect(routeMatchesPort(multiRangeRoute, 8500)).toBeTrue();
expect(routeMatchesPort(multiRangeRoute, 100)).toBeFalse();
@@ -420,55 +450,56 @@ tap.test('Edge Case - Port Range Matching', async () => {
// --------------------------------- Wildcard Domain Tests ---------------------------------
tap.test('Wildcard Domain Handling', async () => {
// Create routes with different wildcard patterns
const simpleDomainRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
const wildcardSubdomainRoute = createHttpRoute('*.example.com', { host: 'server2', port: 3001 });
const specificSubdomainRoute = createHttpRoute('api.example.com', { host: 'server3', port: 3002 });
const simpleDomainRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
name: 'HTTP Route for example.com'
};
const wildcardSubdomainRoute: IRouteConfig = {
match: { ports: 80, domains: '*.example.com' },
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
name: 'HTTP Route for *.example.com'
};
const specificSubdomainRoute: IRouteConfig = {
match: { ports: 80, domains: 'api.example.com' },
action: { type: 'forward', targets: [{ host: 'server3', port: 3002 }] },
name: 'HTTP Route for api.example.com'
};
// Set explicit priorities to ensure deterministic matching
specificSubdomainRoute.priority = 200; // Highest priority for specific domain
wildcardSubdomainRoute.priority = 100; // Medium priority for wildcard
simpleDomainRoute.priority = 50; // Lowest priority for generic domain
specificSubdomainRoute.priority = 200;
wildcardSubdomainRoute.priority = 100;
simpleDomainRoute.priority = 50;
const routes = [simpleDomainRoute, wildcardSubdomainRoute, specificSubdomainRoute];
// Test exact domain match
expect(routeMatchesDomain(simpleDomainRoute, 'example.com')).toBeTrue();
expect(routeMatchesDomain(simpleDomainRoute, 'sub.example.com')).toBeFalse();
// Test wildcard subdomain match
expect(routeMatchesDomain(wildcardSubdomainRoute, 'any.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardSubdomainRoute, 'nested.sub.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardSubdomainRoute, 'example.com')).toBeFalse();
// Test specific subdomain match
expect(routeMatchesDomain(specificSubdomainRoute, 'api.example.com')).toBeTrue();
expect(routeMatchesDomain(specificSubdomainRoute, 'other.example.com')).toBeFalse();
expect(routeMatchesDomain(specificSubdomainRoute, 'sub.api.example.com')).toBeFalse();
// Test finding best match when multiple domains match
const specificSubdomainRequest = { domain: 'api.example.com', port: 80 };
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
expect(bestSpecificMatch).not.toBeUndefined();
if (bestSpecificMatch) {
// Find which route was matched
const matchedPort = bestSpecificMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the specific subdomain route (with highest priority)
expect(bestSpecificMatch.priority).toEqual(200);
}
// Test with a subdomain that matches wildcard but not specific
const otherSubdomainRequest = { domain: 'other.example.com', port: 80 };
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
expect(bestWildcardMatch).not.toBeUndefined();
if (bestWildcardMatch) {
// Find which route was matched
const matchedPort = bestWildcardMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the wildcard subdomain route (with medium priority)
expect(bestWildcardMatch.priority).toEqual(100);
}
});
@@ -476,56 +507,83 @@ tap.test('Wildcard Domain Handling', async () => {
// --------------------------------- Integration Tests ---------------------------------
tap.test('Route Integration - Combining Multiple Route Types', async () => {
// Create a comprehensive set of routes for a full application
const routes: IRouteConfig[] = [
// Main website with HTTPS and HTTP redirect
...createCompleteHttpsServer('example.com', { host: 'web-server', port: 8080 }, {
certificate: 'auto'
}),
// API endpoints
createApiRoute('api.example.com', '/v1', { host: 'api-server', port: 3000 }, {
useTls: true,
certificate: 'auto',
addCorsHeaders: true
}),
// WebSocket for real-time updates
createWebSocketRoute('ws.example.com', '/live', { host: 'websocket-server', port: 5000 }, {
useTls: true,
certificate: 'auto'
}),
// Legacy system with passthrough
createHttpsPassthroughRoute('legacy.example.com', { host: 'legacy-server', port: 443 })
{
match: { ports: 443, domains: 'example.com' },
action: {
type: 'forward',
targets: [{ host: 'web-server', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
name: 'HTTPS Terminate Route for example.com'
},
{
match: { ports: 80, domains: 'example.com' },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
},
name: 'HTTP to HTTPS Redirect for example.com'
},
{
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
action: {
type: 'forward',
targets: [{ host: 'api-server', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
headers: {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
},
priority: 100,
name: 'API Route for api.example.com'
},
{
match: { ports: 443, domains: 'ws.example.com', path: '/live' },
action: {
type: 'forward',
targets: [{ host: 'websocket-server', port: 5000 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: { enabled: true }
},
priority: 100,
name: 'WebSocket Route for ws.example.com'
},
{
match: { ports: 443, domains: 'legacy.example.com' },
action: {
type: 'forward',
targets: [{ host: 'legacy-server', port: 443 }],
tls: { mode: 'passthrough' }
},
name: 'HTTPS Passthrough Route for legacy.example.com'
}
];
// Validate all routes
const validationResult = validateRoutes(routes);
expect(validationResult.valid).toBeTrue();
expect(validationResult.errors.length).toEqual(0);
// Test route matching for different endpoints
// Web server (HTTPS)
const webServerMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
expect(webServerMatch).not.toBeUndefined();
if (webServerMatch) {
expect(webServerMatch.action.type).toEqual('forward');
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
}
// Web server (HTTP redirect via socket handler)
const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(webRedirectMatch).not.toBeUndefined();
if (webRedirectMatch) {
expect(webRedirectMatch.action.type).toEqual('socket-handler');
}
// API server
const apiMatch = findBestMatchingRoute(routes, {
domain: 'api.example.com',
const apiMatch = findBestMatchingRoute(routes, {
domain: 'api.example.com',
port: 443,
path: '/v1/users'
});
@@ -534,10 +592,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(apiMatch.action.type).toEqual('forward');
expect(apiMatch.action.targets[0].host).toEqual('api-server');
}
// WebSocket server
const wsMatch = findBestMatchingRoute(routes, {
domain: 'ws.example.com',
const wsMatch = findBestMatchingRoute(routes, {
domain: 'ws.example.com',
port: 443,
path: '/live'
});
@@ -547,12 +604,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
expect(wsMatch.action.websocket?.enabled).toBeTrue();
}
// Static assets route was removed - static file serving should be handled externally
// Legacy system
const legacyMatch = findBestMatchingRoute(routes, {
domain: 'legacy.example.com',
const legacyMatch = findBestMatchingRoute(routes, {
domain: 'legacy.example.com',
port: 443
});
expect(legacyMatch).not.toBeUndefined();
@@ -565,7 +619,6 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
// --------------------------------- Protocol Match Field Tests ---------------------------------
tap.test('Routes: Should accept protocol field on route match', async () => {
// Create a route with protocol: 'http'
const httpOnlyRoute: IRouteConfig = {
match: {
ports: 443,
@@ -583,16 +636,13 @@ tap.test('Routes: Should accept protocol field on route match', async () => {
name: 'HTTP-only Route',
};
// Validate the route - protocol field should not cause errors
const validation = validateRouteConfig(httpOnlyRoute);
expect(validation.valid).toBeTrue();
// Verify the protocol field is preserved
expect(httpOnlyRoute.match.protocol).toEqual('http');
});
tap.test('Routes: Should accept protocol tcp on route match', async () => {
// Create a route with protocol: 'tcp'
const tcpOnlyRoute: IRouteConfig = {
match: {
ports: 443,
@@ -616,28 +666,26 @@ tap.test('Routes: Should accept protocol tcp on route match', async () => {
});
tap.test('Routes: Protocol field should work with terminate-and-reencrypt', async () => {
// Create a terminate-and-reencrypt route that only accepts HTTP
const reencryptRoute = createHttpsTerminateRoute(
'secure.example.com',
{ host: 'backend', port: 443 },
{ reencrypt: true, certificate: 'auto', name: 'Reencrypt HTTP Route' }
);
const reencryptRoute: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
targets: [{ host: 'backend', port: 443 }],
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
},
name: 'Reencrypt HTTP Route'
};
// Set protocol restriction to http
reencryptRoute.match.protocol = 'http';
// Validate the route
const validation = validateRouteConfig(reencryptRoute);
expect(validation.valid).toBeTrue();
// Verify TLS mode
expect(reencryptRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
// Verify protocol field is preserved
expect(reencryptRoute.match.protocol).toEqual('http');
});
tap.test('Routes: Protocol field should not affect domain/port matching', async () => {
// Routes with and without protocol field should both match the same domain/port
const routeWithProtocol: IRouteConfig = {
match: {
ports: 443,
@@ -669,11 +717,9 @@ tap.test('Routes: Protocol field should not affect domain/port matching', async
const routes = [routeWithProtocol, routeWithoutProtocol];
// Both routes should match the domain/port (protocol is a hint for Rust-side matching)
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 443 });
expect(matches.length).toEqual(2);
// The one with higher priority should be first
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
expect(best).not.toBeUndefined();
expect(best!.name).toEqual('With Protocol');
@@ -696,11 +742,9 @@ tap.test('Routes: Protocol field preserved through route cloning', async () => {
const cloned = cloneRoute(original);
// Verify protocol is preserved in clone
expect(cloned.match.protocol).toEqual('http');
expect(cloned.action.tls?.mode).toEqual('terminate-and-reencrypt');
// Modify clone should not affect original
cloned.match.protocol = 'tcp';
expect(original.match.protocol).toEqual('http');
});
@@ -720,10 +764,9 @@ tap.test('Routes: Protocol field preserved through route merging', async () => {
name: 'Merge Base',
};
// Merge with override that changes name but not protocol
const merged = mergeRouteConfigs(base, { name: 'Merged Route' });
expect(merged.match.protocol).toEqual('http');
expect(merged.name).toEqual('Merged Route');
});
export default tap.start();
export default tap.start();
+213 -468
View File
@@ -1,21 +1,7 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
// Import from individual modules to avoid naming conflicts
import {
// Route helpers
createHttpRoute,
createHttpsTerminateRoute,
createApiRoute,
createWebSocketRoute,
createHttpToHttpsRedirect,
createHttpsPassthroughRoute,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
// Route validators
validateRouteConfig,
validateRoutes,
isValidDomain,
@@ -27,7 +13,6 @@ import {
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import {
// Route utilities
mergeRouteConfigs,
findMatchingRoutes,
findBestMatchingRoute,
@@ -39,16 +24,6 @@ import {
cloneRoute
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
// Route patterns
createApiGatewayRoute,
createWebSocketRoute as createWebSocketPattern,
createLoadBalancerRoute as createLbPattern,
addRateLimiting,
addBasicAuth,
addJwtAuth
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type {
IRouteConfig,
IRouteMatch,
@@ -84,7 +59,7 @@ tap.test('Route Validation - isValidPort', async () => {
expect(isValidPort(443)).toBeTrue();
expect(isValidPort(8080)).toBeTrue();
expect(isValidPort([80, 443])).toBeTrue();
// Invalid ports
expect(isValidPort(0)).toBeFalse();
expect(isValidPort(65536)).toBeFalse();
@@ -101,7 +76,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
const validResult = validateRouteMatch(validMatch);
expect(validResult.valid).toBeTrue();
expect(validResult.errors.length).toEqual(0);
// Invalid match configuration (invalid domain)
const invalidMatch: IRouteMatch = {
ports: 80,
@@ -111,7 +86,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
expect(invalidResult.valid).toBeFalse();
expect(invalidResult.errors.length).toBeGreaterThan(0);
expect(invalidResult.errors[0]).toInclude('Invalid domain');
// Invalid match configuration (invalid port)
const invalidPortMatch: IRouteMatch = {
ports: 0,
@@ -121,7 +96,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
expect(invalidPortResult.valid).toBeFalse();
expect(invalidPortResult.errors.length).toBeGreaterThan(0);
expect(invalidPortResult.errors[0]).toInclude('Invalid port');
// Test path validation
const invalidPathMatch: IRouteMatch = {
ports: 80,
@@ -146,7 +121,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
const validForwardResult = validateRouteAction(validForwardAction);
expect(validForwardResult.valid).toBeTrue();
expect(validForwardResult.errors.length).toEqual(0);
// Valid socket-handler action
const validSocketAction: IRouteAction = {
type: 'socket-handler',
@@ -157,7 +132,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
const validSocketResult = validateRouteAction(validSocketAction);
expect(validSocketResult.valid).toBeTrue();
expect(validSocketResult.errors.length).toEqual(0);
// Invalid action (missing targets)
const invalidAction: IRouteAction = {
type: 'forward'
@@ -166,7 +141,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
expect(invalidResult.valid).toBeFalse();
expect(invalidResult.errors.length).toBeGreaterThan(0);
expect(invalidResult.errors[0]).toInclude('Targets array is required');
// Invalid action (missing socket handler)
const invalidSocketAction: IRouteAction = {
type: 'socket-handler'
@@ -179,11 +154,15 @@ tap.test('Route Validation - validateRouteAction', async () => {
tap.test('Route Validation - validateRouteConfig', async () => {
// Valid route config
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const validRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
const validResult = validateRouteConfig(validRoute);
expect(validResult.valid).toBeTrue();
expect(validResult.errors.length).toEqual(0);
// Invalid route config (missing targets)
const invalidRoute: IRouteConfig = {
match: {
@@ -203,7 +182,11 @@ tap.test('Route Validation - validateRouteConfig', async () => {
tap.test('Route Validation - validateRoutes', async () => {
// Create valid and invalid routes
const routes = [
createHttpRoute('example.com', { host: 'localhost', port: 3000 }),
{
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
} as IRouteConfig,
{
match: {
domains: 'invalid..domain',
@@ -217,9 +200,13 @@ tap.test('Route Validation - validateRoutes', async () => {
}
}
} as IRouteConfig,
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 })
{
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'HTTPS Terminate Route for secure.example.com',
} as IRouteConfig
];
const result = validateRoutes(routes);
expect(result.valid).toBeFalse();
expect(result.errors.length).toEqual(1);
@@ -230,13 +217,13 @@ tap.test('Route Validation - validateRoutes', async () => {
tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
// Forward action
const forwardRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const forwardRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
expect(hasRequiredPropertiesForAction(forwardRoute, 'forward')).toBeTrue();
// Socket handler action (redirect functionality)
const redirectRoute = createHttpToHttpsRedirect('example.com');
expect(hasRequiredPropertiesForAction(redirectRoute, 'socket-handler')).toBeTrue();
// Socket handler action
const socketRoute: IRouteConfig = {
match: {
@@ -252,7 +239,7 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
name: 'Socket Handler Route'
};
expect(hasRequiredPropertiesForAction(socketRoute, 'socket-handler')).toBeTrue();
// Missing required properties
const invalidForwardRoute: IRouteConfig = {
match: {
@@ -269,9 +256,13 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
tap.test('Route Validation - assertValidRoute', async () => {
// Valid route
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const validRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
expect(() => assertValidRoute(validRoute)).not.toThrow();
// Invalid route
const invalidRoute: IRouteConfig = {
match: {
@@ -290,8 +281,12 @@ tap.test('Route Validation - assertValidRoute', async () => {
tap.test('Route Utilities - mergeRouteConfigs', async () => {
// Base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const baseRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
// Override with different name and port
const overrideRoute: Partial<IRouteConfig> = {
name: 'Merged Route',
@@ -299,16 +294,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
ports: 8080
}
};
// Merge configs
const mergedRoute = mergeRouteConfigs(baseRoute, overrideRoute);
// Check merged properties
expect(mergedRoute.name).toEqual('Merged Route');
expect(mergedRoute.match.ports).toEqual(8080);
expect(mergedRoute.match.domains).toEqual('example.com');
expect(mergedRoute.action.type).toEqual('forward');
// Test merging action properties
const actionOverride: Partial<IRouteConfig> = {
action: {
@@ -319,11 +314,11 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
}]
}
};
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
// Test replacing action with socket handler
const typeChangeOverride: Partial<IRouteConfig> = {
action: {
@@ -336,7 +331,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
}
}
};
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
expect(typeChangedRoute.action.type).toEqual('socket-handler');
expect(typeChangedRoute.action.socketHandler).toBeDefined();
@@ -345,37 +340,53 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
tap.test('Route Matching - routeMatchesDomain', async () => {
// Create route with wildcard domain
const wildcardRoute = createHttpRoute('*.example.com', { host: 'localhost', port: 3000 });
const wildcardRoute: IRouteConfig = {
match: { ports: 80, domains: '*.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for *.example.com',
};
// Create route with exact domain
const exactRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const exactRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
// Create route with multiple domains
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 });
const multiDomainRoute: IRouteConfig = {
match: { ports: 80, domains: ['example.com', 'example.org'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com,example.org',
};
// Test wildcard domain matching
expect(routeMatchesDomain(wildcardRoute, 'sub.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardRoute, 'another.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardRoute, 'example.com')).toBeFalse();
expect(routeMatchesDomain(wildcardRoute, 'example.org')).toBeFalse();
// Test exact domain matching
expect(routeMatchesDomain(exactRoute, 'example.com')).toBeTrue();
expect(routeMatchesDomain(exactRoute, 'sub.example.com')).toBeFalse();
// Test multiple domains matching
expect(routeMatchesDomain(multiDomainRoute, 'example.com')).toBeTrue();
expect(routeMatchesDomain(multiDomainRoute, 'example.org')).toBeTrue();
expect(routeMatchesDomain(multiDomainRoute, 'example.net')).toBeFalse();
// Test case insensitivity
expect(routeMatchesDomain(exactRoute, 'Example.Com')).toBeTrue();
});
tap.test('Route Matching - routeMatchesPort', async () => {
// Create routes with different port configurations
const singlePortRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const singlePortRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
const multiPortRoute: IRouteConfig = {
match: {
domains: 'example.com',
@@ -389,7 +400,7 @@ tap.test('Route Matching - routeMatchesPort', async () => {
}]
}
};
const portRangeRoute: IRouteConfig = {
match: {
domains: 'example.com',
@@ -403,16 +414,16 @@ tap.test('Route Matching - routeMatchesPort', async () => {
}]
}
};
// Test single port matching
expect(routeMatchesPort(singlePortRoute, 80)).toBeTrue();
expect(routeMatchesPort(singlePortRoute, 443)).toBeFalse();
// Test multi-port matching
expect(routeMatchesPort(multiPortRoute, 80)).toBeTrue();
expect(routeMatchesPort(multiPortRoute, 8080)).toBeTrue();
expect(routeMatchesPort(multiPortRoute, 3000)).toBeFalse();
// Test port range matching
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
@@ -437,11 +448,11 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}]
}
};
// Test prefix matching with wildcard (not trailing slash)
const prefixPathRoute: IRouteConfig = {
match: {
domains: 'example.com',
domains: 'example.com',
ports: 80,
path: '/api/*'
},
@@ -453,7 +464,7 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}]
}
};
const wildcardPathRoute: IRouteConfig = {
match: {
domains: 'example.com',
@@ -468,17 +479,17 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}]
}
};
// Test exact path matching
expect(routeMatchesPath(exactPathRoute, '/api')).toBeTrue();
expect(routeMatchesPath(exactPathRoute, '/api/users')).toBeFalse();
expect(routeMatchesPath(exactPathRoute, '/app')).toBeFalse();
// Test prefix path matching with wildcard
expect(routeMatchesPath(prefixPathRoute, '/api/')).toBeFalse(); // Wildcard requires content after /api/
expect(routeMatchesPath(prefixPathRoute, '/api/users')).toBeTrue();
expect(routeMatchesPath(prefixPathRoute, '/app/')).toBeFalse();
// Test wildcard path matching
expect(routeMatchesPath(wildcardPathRoute, '/api/users')).toBeTrue();
expect(routeMatchesPath(wildcardPathRoute, '/api/products')).toBeTrue();
@@ -504,30 +515,59 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
}]
}
};
// Test header matching
expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'application/json',
'X-Custom-Header': 'value'
})).toBeTrue();
expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'application/json',
'X-Custom-Header': 'value',
'Extra-Header': 'something'
})).toBeTrue();
expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'application/json'
})).toBeFalse();
expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'text/html',
'X-Custom-Header': 'value'
})).toBeFalse();
const regexHeaderRoute: IRouteConfig = {
match: {
domains: 'example.com',
ports: 80,
headers: {
'Content-Type': /^application\/(json|problem\+json)$/i,
}
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 3000
}]
}
};
expect(routeMatchesHeaders(regexHeaderRoute, {
'Content-Type': 'Application/Problem+Json',
})).toBeTrue();
expect(routeMatchesHeaders(regexHeaderRoute, {
'Content-Type': 'text/html',
})).toBeFalse();
// Route without header matching should match any headers
const noHeaderRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const noHeaderRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
expect(routeMatchesHeaders(noHeaderRoute, {
'Content-Type': 'application/json'
})).toBeTrue();
@@ -536,78 +576,118 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
tap.test('Route Finding - findMatchingRoutes', async () => {
// Create multiple routes
const routes: IRouteConfig[] = [
createHttpRoute('example.com', { host: 'localhost', port: 3000 }),
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 }),
createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3002 }),
createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3003 })
{
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
},
{
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'HTTPS Route for secure.example.com',
},
{
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'API Route for api.example.com',
},
{
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3003 }], tls: { mode: 'terminate', certificate: 'auto' }, websocket: { enabled: true } },
name: 'WebSocket Route for ws.example.com',
},
];
// Set priorities
routes[0].priority = 10;
routes[1].priority = 20;
routes[2].priority = 30;
routes[3].priority = 40;
// Find routes for different criteria
const httpMatches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
expect(httpMatches.length).toEqual(1);
expect(httpMatches[0].name).toInclude('HTTP Route');
const httpsMatches = findMatchingRoutes(routes, { domain: 'secure.example.com', port: 443 });
expect(httpsMatches.length).toEqual(1);
expect(httpsMatches[0].name).toInclude('HTTPS Route');
const apiMatches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/v1/users' });
expect(apiMatches.length).toEqual(1);
expect(apiMatches[0].name).toInclude('API Route');
const wsMatches = findMatchingRoutes(routes, { domain: 'ws.example.com', path: '/socket' });
expect(wsMatches.length).toEqual(1);
expect(wsMatches[0].name).toInclude('WebSocket Route');
// Test finding multiple routes that match same criteria
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const route1: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
route1.priority = 10;
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 });
const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
name: 'HTTP Route for example.com',
};
route2.priority = 20;
route2.match.path = '/api';
const multiMatchRoutes = [route1, route2];
const multiMatches = findMatchingRoutes(multiMatchRoutes, { domain: 'example.com', port: 80 });
expect(multiMatches.length).toEqual(2);
expect(multiMatches[0].priority).toEqual(20); // Higher priority should be first
expect(multiMatches[1].priority).toEqual(10);
// Test disabled routes
const disabledRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const disabledRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
disabledRoute.enabled = false;
const enabledRoutes = findMatchingRoutes([disabledRoute], { domain: 'example.com', port: 80 });
expect(enabledRoutes.length).toEqual(0);
});
tap.test('Route Finding - findBestMatchingRoute', async () => {
// Create multiple routes with different priorities
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const route1: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
route1.priority = 10;
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 });
const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
name: 'HTTP Route for example.com',
};
route2.priority = 20;
route2.match.path = '/api';
const route3 = createHttpRoute('example.com', { host: 'localhost', port: 3002 });
const route3: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }] },
name: 'HTTP Route for example.com',
};
route3.priority = 30;
route3.match.path = '/api/users';
const routes = [route1, route2, route3];
// Find best route for different criteria
const bestGeneral = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(bestGeneral).not.toBeUndefined();
expect(bestGeneral?.priority).toEqual(30);
// Test when no routes match
const noMatch = findBestMatchingRoute(routes, { domain: 'unknown.com', port: 80 });
expect(noMatch).toBeUndefined();
@@ -615,389 +695,54 @@ tap.test('Route Finding - findBestMatchingRoute', async () => {
tap.test('Route Utilities - generateRouteId', async () => {
// Test ID generation for different route types
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
const httpRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
const httpId = generateRouteId(httpRoute);
expect(httpId).toInclude('example-com');
expect(httpId).toInclude('80');
expect(httpId).toInclude('forward');
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 });
const httpsRoute: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'HTTPS Terminate Route for secure.example.com',
};
const httpsId = generateRouteId(httpsRoute);
expect(httpsId).toInclude('secure-example-com');
expect(httpsId).toInclude('443');
expect(httpsId).toInclude('forward');
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 });
const multiDomainRoute: IRouteConfig = {
match: { ports: 80, domains: ['example.com', 'example.org'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com,example.org',
};
const multiDomainId = generateRouteId(multiDomainRoute);
expect(multiDomainId).toInclude('example-com-example-org');
});
tap.test('Route Utilities - cloneRoute', async () => {
// Create a route and clone it
const originalRoute = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
certificate: 'auto',
name: 'Original Route'
});
const originalRoute: IRouteConfig = {
match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'Original Route',
};
const clonedRoute = cloneRoute(originalRoute);
// Check that the values are identical
expect(clonedRoute.name).toEqual(originalRoute.name);
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
// Modify the clone and check that the original is unchanged
clonedRoute.name = 'Modified Clone';
expect(originalRoute.name).toEqual('Original Route');
});
// --------------------------------- Route Helper Tests ---------------------------------
tap.test('Route Helpers - createHttpRoute', async () => {
const route = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(80);
expect(route.action.type).toEqual('forward');
expect(route.action.targets?.[0]?.host).toEqual('localhost');
expect(route.action.targets?.[0]?.port).toEqual(3000);
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createHttpsTerminateRoute', async () => {
const route = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
certificate: 'auto'
});
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('terminate');
expect(route.action.tls.certificate).toEqual('auto');
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createHttpToHttpsRedirect', async () => {
const route = createHttpToHttpsRedirect('example.com');
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(80);
expect(route.action.type).toEqual('socket-handler');
expect(route.action.socketHandler).toBeDefined();
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createHttpsPassthroughRoute', async () => {
const route = createHttpsPassthroughRoute('example.com', { host: 'localhost', port: 3000 });
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('passthrough');
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createCompleteHttpsServer', async () => {
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 3000 }, {
certificate: 'auto'
});
expect(routes.length).toEqual(2);
// HTTPS route
expect(routes[0].match.domains).toEqual('example.com');
expect(routes[0].match.ports).toEqual(443);
expect(routes[0].action.type).toEqual('forward');
expect(routes[0].action.tls.mode).toEqual('terminate');
// HTTP redirect route
expect(routes[1].match.domains).toEqual('example.com');
expect(routes[1].match.ports).toEqual(80);
expect(routes[1].action.type).toEqual('socket-handler');
const validation1 = validateRouteConfig(routes[0]);
const validation2 = validateRouteConfig(routes[1]);
expect(validation1.valid).toBeTrue();
expect(validation2.valid).toBeTrue();
});
// createStaticFileRoute has been removed - static file serving should be handled by
// external servers (nginx/apache) behind the proxy
tap.test('Route Helpers - createApiRoute', async () => {
const route = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
useTls: true,
certificate: 'auto',
addCorsHeaders: true
});
expect(route.match.domains).toEqual('api.example.com');
expect(route.match.ports).toEqual(443);
expect(route.match.path).toEqual('/v1/*');
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('terminate');
// Check CORS headers if they exist
if (route.headers && route.headers.response) {
expect(route.headers.response['Access-Control-Allow-Origin']).toEqual('*');
}
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createWebSocketRoute', async () => {
const route = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3000 }, {
useTls: true,
certificate: 'auto',
pingInterval: 15000
});
expect(route.match.domains).toEqual('ws.example.com');
expect(route.match.ports).toEqual(443);
expect(route.match.path).toEqual('/socket');
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('terminate');
// Check websocket configuration if it exists
if (route.action.websocket) {
expect(route.action.websocket.enabled).toBeTrue();
expect(route.action.websocket.pingInterval).toEqual(15000);
}
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createLoadBalancerRoute', async () => {
const route = createLoadBalancerRoute(
'loadbalancer.example.com',
['server1.local', 'server2.local', 'server3.local'],
8080,
{
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
);
expect(route.match.domains).toEqual('loadbalancer.example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(route.action.targets).toBeDefined();
if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
expect((route.action.targets[0].host as string[]).length).toEqual(3);
}
expect(route.action.targets?.[0]?.port).toEqual(8080);
expect(route.action.tls.mode).toEqual('terminate');
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
// --------------------------------- Route Pattern Tests ---------------------------------
tap.test('Route Patterns - createApiGatewayRoute', async () => {
// Create API Gateway route
const apiGatewayRoute = createApiGatewayRoute(
'api.example.com',
'/v1',
{ host: 'localhost', port: 3000 },
{
useTls: true,
addCorsHeaders: true
}
);
// Validate route configuration
expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
expect(apiGatewayRoute.match.path).toInclude('/v1');
expect(apiGatewayRoute.action.type).toEqual('forward');
expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (apiGatewayRoute.action.tls) {
expect(apiGatewayRoute.action.tls.mode).toEqual('terminate');
}
// Check CORS headers
if (apiGatewayRoute.headers && apiGatewayRoute.headers.response) {
expect(apiGatewayRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
}
const result = validateRouteConfig(apiGatewayRoute);
expect(result.valid).toBeTrue();
});
// createStaticFileServerRoute has been removed - static file serving should be handled by
// external servers (nginx/apache) behind the proxy
tap.test('Route Patterns - createWebSocketPattern', async () => {
// Create WebSocket route pattern
const wsRoute = createWebSocketPattern(
'ws.example.com',
{ host: 'localhost', port: 3000 },
{
useTls: true,
path: '/socket',
pingInterval: 10000
}
);
// Validate route configuration
expect(wsRoute.match.domains).toEqual('ws.example.com');
expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (wsRoute.action.tls) {
expect(wsRoute.action.tls.mode).toEqual('terminate');
}
// Check websocket configuration if it exists
if (wsRoute.action.websocket) {
expect(wsRoute.action.websocket.enabled).toBeTrue();
expect(wsRoute.action.websocket.pingInterval).toEqual(10000);
}
const result = validateRouteConfig(wsRoute);
expect(result.valid).toBeTrue();
});
tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
// Create load balancer route pattern with missing algorithm as it might not be implemented yet
try {
const lbRoute = createLbPattern(
'lb.example.com',
[
{ host: 'server1.local', port: 8080 },
{ host: 'server2.local', port: 8080 },
{ host: 'server3.local', port: 8080 }
],
{
useTls: true
}
);
// Validate route configuration
expect(lbRoute.match.domains).toEqual('lb.example.com');
expect(lbRoute.action.type).toEqual('forward');
// Check target hosts
if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
}
// Check TLS configuration
if (lbRoute.action.tls) {
expect(lbRoute.action.tls.mode).toEqual('terminate');
}
const result = validateRouteConfig(lbRoute);
expect(result.valid).toBeTrue();
} catch (error) {
// If the pattern is not implemented yet, skip this test
console.log('Load balancer pattern might not be fully implemented yet');
}
});
tap.test('Route Security - addRateLimiting', async () => {
// Create base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
// Add rate limiting
const secureRoute = addRateLimiting(baseRoute, {
maxRequests: 100,
window: 60, // 1 minute
keyBy: 'ip'
});
// Check if rate limiting is applied
if (secureRoute.security) {
expect(secureRoute.security.rateLimit?.enabled).toBeTrue();
expect(secureRoute.security.rateLimit?.maxRequests).toEqual(100);
expect(secureRoute.security.rateLimit?.window).toEqual(60);
expect(secureRoute.security.rateLimit?.keyBy).toEqual('ip');
} else {
// Skip this test if security features are not implemented yet
console.log('Security features not implemented yet in route configuration');
}
// Just check that the route itself is valid
const result = validateRouteConfig(secureRoute);
expect(result.valid).toBeTrue();
});
tap.test('Route Security - addBasicAuth', async () => {
// Create base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
// Add basic authentication
const authRoute = addBasicAuth(baseRoute, {
users: [
{ username: 'admin', password: 'secret' },
{ username: 'user', password: 'password' }
],
realm: 'Protected Area',
excludePaths: ['/public']
});
// Check if basic auth is applied
if (authRoute.security) {
expect(authRoute.security.basicAuth?.enabled).toBeTrue();
expect(authRoute.security.basicAuth?.users.length).toEqual(2);
expect(authRoute.security.basicAuth?.realm).toEqual('Protected Area');
expect(authRoute.security.basicAuth?.excludePaths).toInclude('/public');
} else {
// Skip this test if security features are not implemented yet
console.log('Security features not implemented yet in route configuration');
}
// Check that the route itself is valid
const result = validateRouteConfig(authRoute);
expect(result.valid).toBeTrue();
});
tap.test('Route Security - addJwtAuth', async () => {
// Create base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
// Add JWT authentication
const jwtRoute = addJwtAuth(baseRoute, {
secret: 'your-jwt-secret-key',
algorithm: 'HS256',
issuer: 'auth.example.com',
audience: 'api.example.com',
expiresIn: 3600
});
// Check if JWT auth is applied
if (jwtRoute.security) {
expect(jwtRoute.security.jwtAuth?.enabled).toBeTrue();
expect(jwtRoute.security.jwtAuth?.secret).toEqual('your-jwt-secret-key');
expect(jwtRoute.security.jwtAuth?.algorithm).toEqual('HS256');
expect(jwtRoute.security.jwtAuth?.issuer).toEqual('auth.example.com');
expect(jwtRoute.security.jwtAuth?.audience).toEqual('api.example.com');
expect(jwtRoute.security.jwtAuth?.expiresIn).toEqual(3600);
} else {
// Skip this test if security features are not implemented yet
console.log('Security features not implemented yet in route configuration');
}
// Check that the route itself is valid
const result = validateRouteConfig(jwtRoute);
expect(result.valid).toBeTrue();
});
export default tap.start();
export default tap.start();
+192
View File
@@ -0,0 +1,192 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import type { ISmartProxyOptions } from '../ts/proxies/smart-proxy/models/interfaces.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
import { RoutePreprocessor } from '../ts/proxies/smart-proxy/route-preprocessor.js';
import { buildRustProxyOptions } from '../ts/proxies/smart-proxy/utils/rust-config.js';
tap.test('Rust contract - preprocessor serializes regex headers for Rust', async () => {
const route: IRouteConfig = {
name: 'contract-route',
match: {
ports: [443, { from: 8443, to: 8444 }],
domains: ['api.example.com', '*.example.com'],
transport: 'udp',
protocol: 'http3',
headers: {
'Content-Type': /^application\/json$/i,
},
},
action: {
type: 'forward',
targets: [{
match: {
ports: [443],
path: '/api/*',
method: ['GET'],
headers: {
'X-Env': /^(prod|stage)$/,
},
},
host: ['backend-a', 'backend-b'],
port: 'preserve',
sendProxyProtocol: true,
backendTransport: 'tcp',
}],
tls: {
mode: 'terminate',
certificate: 'auto',
},
sendProxyProtocol: true,
udp: {
maxSessionsPerIP: 321,
quic: {
enableHttp3: true,
},
},
},
security: {
ipAllowList: [{
ip: '10.0.0.0/8',
domains: ['api.example.com'],
}],
},
};
const preprocessor = new RoutePreprocessor();
const [rustRoute] = preprocessor.preprocessForRust([route]);
expect(rustRoute.match.headers?.['Content-Type']).toEqual('/^application\\/json$/i');
expect(rustRoute.match.transport).toEqual('udp');
expect(rustRoute.match.protocol).toEqual('http3');
expect(rustRoute.action.targets?.[0].match?.headers?.['X-Env']).toEqual('/^(prod|stage)$/');
expect(rustRoute.action.targets?.[0].port).toEqual('preserve');
expect(rustRoute.action.targets?.[0].backendTransport).toEqual('tcp');
expect(rustRoute.action.sendProxyProtocol).toBeTrue();
expect(rustRoute.action.udp?.maxSessionsPerIp).toEqual(321);
});
tap.test('Rust contract - preprocessor converts dynamic targets to relay-safe payloads', async () => {
const route: IRouteConfig = {
name: 'dynamic-contract-route',
match: {
ports: 8080,
},
action: {
type: 'forward',
targets: [{
host: () => 'dynamic-backend.internal',
port: () => 9443,
}],
},
};
const preprocessor = new RoutePreprocessor();
const [rustRoute] = preprocessor.preprocessForRust([route]);
expect(rustRoute.action.type).toEqual('socket-handler');
expect(rustRoute.action.targets?.[0].host).toEqual('localhost');
expect(rustRoute.action.targets?.[0].port).toEqual(0);
expect(preprocessor.getOriginalRoute('dynamic-contract-route')).toEqual(route);
});
tap.test('Rust contract - top-level config keeps shared SmartProxy settings', async () => {
const settings: ISmartProxyOptions = {
routes: [{
name: 'top-level-contract-route',
match: {
ports: 443,
domains: 'api.example.com',
},
action: {
type: 'forward',
targets: [{
host: 'backend.internal',
port: 8443,
}],
tls: {
mode: 'terminate',
certificate: 'auto',
},
},
}],
preserveSourceIP: true,
proxyIPs: ['10.0.0.1'],
acceptProxyProtocol: true,
sendProxyProtocol: true,
noDelay: true,
keepAlive: true,
keepAliveInitialDelay: 1500,
maxPendingDataSize: 4096,
disableInactivityCheck: true,
enableKeepAliveProbes: true,
enableDetailedLogging: true,
enableTlsDebugLogging: true,
enableRandomizedTimeouts: true,
connectionTimeout: 5000,
initialDataTimeout: 7000,
socketTimeout: 9000,
inactivityCheckInterval: 1100,
maxConnectionLifetime: 13000,
inactivityTimeout: 15000,
gracefulShutdownTimeout: 17000,
maxConnectionsPerIP: 20,
connectionRateLimitPerMinute: 30,
keepAliveTreatment: 'extended',
keepAliveInactivityMultiplier: 2,
extendedKeepAliveLifetime: 19000,
metrics: {
enabled: true,
sampleIntervalMs: 250,
retentionSeconds: 60,
},
acme: {
enabled: true,
email: 'ops@example.com',
environment: 'staging',
useProduction: false,
skipConfiguredCerts: true,
renewThresholdDays: 14,
renewCheckIntervalHours: 12,
autoRenew: true,
port: 80,
},
};
const preprocessor = new RoutePreprocessor();
const routes = preprocessor.preprocessForRust(settings.routes);
const config = buildRustProxyOptions(settings, routes);
expect(config.preserveSourceIp).toBeTrue();
expect(config.proxyIps).toEqual(['10.0.0.1']);
expect(config.acceptProxyProtocol).toBeTrue();
expect(config.sendProxyProtocol).toBeTrue();
expect(config.noDelay).toBeTrue();
expect(config.keepAlive).toBeTrue();
expect(config.keepAliveInitialDelay).toEqual(1500);
expect(config.maxPendingDataSize).toEqual(4096);
expect(config.disableInactivityCheck).toBeTrue();
expect(config.enableKeepAliveProbes).toBeTrue();
expect(config.enableDetailedLogging).toBeTrue();
expect(config.enableTlsDebugLogging).toBeTrue();
expect(config.enableRandomizedTimeouts).toBeTrue();
expect(config.connectionTimeout).toEqual(5000);
expect(config.initialDataTimeout).toEqual(7000);
expect(config.socketTimeout).toEqual(9000);
expect(config.inactivityCheckInterval).toEqual(1100);
expect(config.maxConnectionLifetime).toEqual(13000);
expect(config.inactivityTimeout).toEqual(15000);
expect(config.gracefulShutdownTimeout).toEqual(17000);
expect(config.maxConnectionsPerIp).toEqual(20);
expect(config.connectionRateLimitPerMinute).toEqual(30);
expect(config.keepAliveTreatment).toEqual('extended');
expect(config.keepAliveInactivityMultiplier).toEqual(2);
expect(config.extendedKeepAliveLifetime).toEqual(19000);
expect(config.metrics?.sampleIntervalMs).toEqual(250);
expect(config.acme?.email).toEqual('ops@example.com');
expect(config.acme?.environment).toEqual('staging');
expect(config.acme?.skipConfiguredCerts).toBeTrue();
expect(config.acme?.renewThresholdDays).toEqual(14);
});
export default tap.start();
+418
View File
@@ -0,0 +1,418 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import * as http from 'http';
import WebSocket, { WebSocketServer } from 'ws';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
/**
* Helper: create a WebSocket client that connects through the proxy.
* Registers the message handler BEFORE awaiting open to avoid race conditions.
*/
function connectWs(
url: string,
headers: Record<string, string> = {},
opts: WebSocket.ClientOptions = {},
): { ws: WebSocket; messages: string[]; opened: Promise<void> } {
const messages: string[] = [];
const ws = new WebSocket(url, { headers, ...opts });
// Register message handler immediately — before open fires
ws.on('message', (data) => {
messages.push(data.toString());
});
const opened = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('WebSocket open timeout')), 5000);
ws.on('open', () => { clearTimeout(timeout); resolve(); });
ws.on('error', (err) => { clearTimeout(timeout); reject(err); });
});
return { ws, messages, opened };
}
/** Wait until `predicate` returns true, with a hard timeout. */
function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = setTimeout(() => reject(new Error('waitFor timeout')), timeoutMs);
const check = () => {
if (predicate()) { clearTimeout(deadline); resolve(); }
else setTimeout(check, 30);
};
check();
});
}
/** Graceful close helper */
function closeWs(ws: WebSocket): Promise<void> {
return new Promise((resolve) => {
if (ws.readyState === WebSocket.CLOSED) return resolve();
ws.on('close', () => resolve());
ws.close();
setTimeout(resolve, 2000); // fallback
});
}
// ─── Test 1: Basic WebSocket upgrade and bidirectional messaging ───
tap.test('should proxy WebSocket connections with bidirectional messaging', async () => {
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
// Backend: echoes messages with prefix, sends greeting on connect
const backendServer = http.createServer();
const wss = new WebSocketServer({ server: backendServer });
const backendMessages: string[] = [];
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const msg = data.toString();
backendMessages.push(msg);
ws.send(`echo: ${msg}`);
});
ws.send('hello from backend');
});
await new Promise<void>((resolve) => {
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
});
const proxy = new SmartProxy({
routes: [{
name: 'ws-test-route',
match: { ports: PROXY_PORT },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
websocket: { enabled: true },
},
}],
});
await proxy.start();
// Connect client — message handler registered before open
const { ws, messages, opened } = connectWs(
`ws://127.0.0.1:${PROXY_PORT}/`,
{ Host: 'test.local' },
);
await opened;
// Wait for the backend greeting
await waitFor(() => messages.length >= 1);
expect(messages[0]).toEqual('hello from backend');
// Send 3 messages, expect 3 echoes
ws.send('ping 1');
ws.send('ping 2');
ws.send('ping 3');
await waitFor(() => messages.length >= 4);
expect(messages).toContain('echo: ping 1');
expect(messages).toContain('echo: ping 2');
expect(messages).toContain('echo: ping 3');
expect(backendMessages).toInclude('ping 1');
expect(backendMessages).toInclude('ping 2');
expect(backendMessages).toInclude('ping 3');
await closeWs(ws);
await proxy.stop();
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
await new Promise((r) => setTimeout(r, 500));
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
});
// ─── Test 2: Multiple concurrent WebSocket connections ───
tap.test('should handle multiple concurrent WebSocket connections', async () => {
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
const backendServer = http.createServer();
const wss = new WebSocketServer({ server: backendServer });
let connectionCount = 0;
wss.on('connection', (ws) => {
const id = ++connectionCount;
ws.on('message', (data) => {
ws.send(`conn${id}: ${data.toString()}`);
});
});
await new Promise<void>((resolve) => {
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
});
const proxy = new SmartProxy({
routes: [{
name: 'ws-multi-route',
match: { ports: PROXY_PORT },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
websocket: { enabled: true },
},
}],
});
await proxy.start();
const NUM_CLIENTS = 5;
const clients: { ws: WebSocket; messages: string[] }[] = [];
for (let i = 0; i < NUM_CLIENTS; i++) {
const c = connectWs(
`ws://127.0.0.1:${PROXY_PORT}/`,
{ Host: 'test.local' },
);
await c.opened;
clients.push(c);
}
// Each client sends a unique message
for (let i = 0; i < NUM_CLIENTS; i++) {
clients[i].ws.send(`hello from client ${i}`);
}
// Wait for all replies
await waitFor(() => clients.every((c) => c.messages.length >= 1));
for (let i = 0; i < NUM_CLIENTS; i++) {
expect(clients[i].messages.length).toBeGreaterThanOrEqual(1);
expect(clients[i].messages[0]).toInclude(`hello from client ${i}`);
}
expect(connectionCount).toEqual(NUM_CLIENTS);
for (const c of clients) await closeWs(c.ws);
await proxy.stop();
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
await new Promise((r) => setTimeout(r, 500));
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
});
// ─── Test 3: WebSocket with binary data ───
tap.test('should proxy binary WebSocket frames', async () => {
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
const backendServer = http.createServer();
const wss = new WebSocketServer({ server: backendServer });
wss.on('connection', (ws) => {
ws.on('message', (data) => {
ws.send(data, { binary: true });
});
});
await new Promise<void>((resolve) => {
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
});
const proxy = new SmartProxy({
routes: [{
name: 'ws-binary-route',
match: { ports: PROXY_PORT },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
websocket: { enabled: true },
},
}],
});
await proxy.start();
const receivedBuffers: Buffer[] = [];
const ws = new WebSocket(`ws://127.0.0.1:${PROXY_PORT}/`, {
headers: { Host: 'test.local' },
});
ws.on('message', (data) => {
receivedBuffers.push(Buffer.from(data as ArrayBuffer));
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('timeout')), 5000);
ws.on('open', () => { clearTimeout(timeout); resolve(); });
ws.on('error', (err) => { clearTimeout(timeout); reject(err); });
});
// Send a 256-byte buffer with known content
const sentBuffer = Buffer.alloc(256);
for (let i = 0; i < 256; i++) sentBuffer[i] = i;
ws.send(sentBuffer);
await waitFor(() => receivedBuffers.length >= 1);
expect(receivedBuffers[0].length).toEqual(256);
expect(Buffer.compare(receivedBuffers[0], sentBuffer)).toEqual(0);
await closeWs(ws);
await proxy.stop();
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
await new Promise((r) => setTimeout(r, 500));
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
});
// ─── Test 4: WebSocket path and query string preserved ───
tap.test('should preserve path and query string through proxy', async () => {
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
const backendServer = http.createServer();
const wss = new WebSocketServer({ server: backendServer });
let receivedUrl = '';
wss.on('connection', (ws, req) => {
receivedUrl = req.url || '';
ws.send(`url: ${receivedUrl}`);
});
await new Promise<void>((resolve) => {
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
});
const proxy = new SmartProxy({
routes: [{
name: 'ws-path-route',
match: { ports: PROXY_PORT },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
websocket: { enabled: true },
},
}],
});
await proxy.start();
const { ws, messages, opened } = connectWs(
`ws://127.0.0.1:${PROXY_PORT}/chat/room1?token=abc123`,
{ Host: 'test.local' },
);
await opened;
await waitFor(() => messages.length >= 1);
expect(receivedUrl).toEqual('/chat/room1?token=abc123');
expect(messages[0]).toEqual('url: /chat/room1?token=abc123');
await closeWs(ws);
await proxy.stop();
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
await new Promise((r) => setTimeout(r, 500));
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
});
// ─── Test 5: Clean close propagation ───
tap.test('should handle clean WebSocket close from client', async () => {
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
const backendServer = http.createServer();
const wss = new WebSocketServer({ server: backendServer });
let backendGotClose = false;
let backendCloseCode = 0;
wss.on('connection', (ws) => {
ws.on('close', (code) => {
backendGotClose = true;
backendCloseCode = code;
});
ws.on('message', (data) => {
ws.send(data);
});
});
await new Promise<void>((resolve) => {
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
});
const proxy = new SmartProxy({
routes: [{
name: 'ws-close-route',
match: { ports: PROXY_PORT },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
websocket: { enabled: true },
},
}],
});
await proxy.start();
const { ws, messages, opened } = connectWs(
`ws://127.0.0.1:${PROXY_PORT}/`,
{ Host: 'test.local' },
);
await opened;
// Confirm connection works with a round-trip
ws.send('test');
await waitFor(() => messages.length >= 1);
// Close with code 1000
let clientCloseCode = 0;
const closed = new Promise<void>((resolve) => {
ws.on('close', (code) => {
clientCloseCode = code;
resolve();
});
setTimeout(resolve, 3000);
});
ws.close(1000, 'done');
await closed;
// Wait for backend to register
await waitFor(() => backendGotClose, 3000);
expect(backendGotClose).toBeTrue();
expect(clientCloseCode).toEqual(1000);
await proxy.stop();
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
await new Promise((r) => setTimeout(r, 500));
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
});
// ─── Test 6: Large messages ───
tap.test('should handle large WebSocket messages', async () => {
const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2);
const backendServer = http.createServer();
const wss = new WebSocketServer({ server: backendServer, maxPayload: 5 * 1024 * 1024 });
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const buf = Buffer.from(data as ArrayBuffer);
ws.send(`received ${buf.length} bytes`);
});
});
await new Promise<void>((resolve) => {
backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve());
});
const proxy = new SmartProxy({
routes: [{
name: 'ws-large-route',
match: { ports: PROXY_PORT },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: BACKEND_PORT }],
websocket: { enabled: true },
},
}],
});
await proxy.start();
const { ws, messages, opened } = connectWs(
`ws://127.0.0.1:${PROXY_PORT}/`,
{ Host: 'test.local' },
{ maxPayload: 5 * 1024 * 1024 },
);
await opened;
// Send a 1MB message
const largePayload = Buffer.alloc(1024 * 1024, 0x42);
ws.send(largePayload);
await waitFor(() => messages.length >= 1);
expect(messages[0]).toEqual(`received ${1024 * 1024} bytes`);
await closeWs(ws);
await proxy.stop();
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
await new Promise((r) => setTimeout(r, 500));
await assertPortsFree([PROXY_PORT, BACKEND_PORT]);
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '26.3.0',
version: '27.7.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.'
}
@@ -32,6 +32,23 @@ export interface IThroughputHistoryPoint {
/**
* Main metrics interface with clean, grouped API
*/
/**
* Protocol distribution for frontend (clientproxy) or backend (proxyupstream).
* Tracks active and total counts for h1/h2/h3/ws/other.
*/
export interface IProtocolDistribution {
h1Active: number;
h1Total: number;
h2Active: number;
h2Total: number;
h3Active: number;
h3Total: number;
wsActive: number;
wsTotal: number;
otherActive: number;
otherTotal: number;
}
export interface IMetrics {
// Connection metrics
connections: {
@@ -40,6 +57,12 @@ export interface IMetrics {
byRoute(): Map<string, number>;
byIP(): Map<string, number>;
topIPs(limit?: number): Array<{ ip: string; count: number }>;
/** Per-IP domain request counts: IP -> { domain -> count }. */
domainRequestsByIP(): Map<string, Map<string, number>>;
/** Top IP-domain pairs sorted by request count descending. */
topDomainRequests(limit?: number): Array<{ ip: string; domain: string; count: number }>;
frontendProtocols(): IProtocolDistribution;
backendProtocols(): IProtocolDistribution;
};
// Throughput metrics (bytes per second)
+4 -2
View File
@@ -141,8 +141,10 @@ export interface IRouteAuthentication {
* Security options for routes
*/
export interface IRouteSecurity {
// Access control lists
ipAllowList?: string[]; // IP addresses that are allowed to connect
// Access control lists.
// Entries can be plain IP/CIDR strings (full route access) or
// objects { ip, domains } to scope access to specific domains on this route.
ipAllowList?: Array<string | { ip: string; domains: string[] }>;
ipBlockList?: string[]; // IP addresses that are blocked from connecting
// Connection limits
+160
View File
@@ -0,0 +1,160 @@
import type { IProtocolCacheEntry, IProtocolDistribution } from './metrics-types.js';
import type { IAcmeOptions, ISmartProxyOptions } from './interfaces.js';
import type {
IRouteAction,
IRouteConfig,
IRouteMatch,
IRouteTarget,
ITargetMatch,
IRouteUdp,
} from './route-types.js';
export type TRustHeaderMatchers = Record<string, string>;
export interface IRustRouteMatch extends Omit<IRouteMatch, 'headers'> {
headers?: TRustHeaderMatchers;
}
export interface IRustTargetMatch extends Omit<ITargetMatch, 'headers'> {
headers?: TRustHeaderMatchers;
}
export interface IRustRouteTarget extends Omit<IRouteTarget, 'host' | 'port' | 'match'> {
host: string | string[];
port: number | 'preserve';
match?: IRustTargetMatch;
}
export interface IRustRouteUdp extends Omit<IRouteUdp, 'maxSessionsPerIP'> {
maxSessionsPerIp?: number;
}
export interface IRustDefaultConfig extends Omit<NonNullable<ISmartProxyOptions['defaults']>, 'preserveSourceIP'> {
preserveSourceIp?: boolean;
}
export interface IRustRouteAction
extends Omit<IRouteAction, 'targets' | 'socketHandler' | 'datagramHandler' | 'forwardingEngine' | 'nftables' | 'udp'> {
targets?: IRustRouteTarget[];
udp?: IRustRouteUdp;
}
export interface IRustRouteConfig extends Omit<IRouteConfig, 'match' | 'action'> {
match: IRustRouteMatch;
action: IRustRouteAction;
}
export interface IRustAcmeOptions extends Omit<IAcmeOptions, 'routeForwards'> {}
export interface IRustProxyOptions {
routes: IRustRouteConfig[];
preserveSourceIp?: boolean;
proxyIps?: string[];
acceptProxyProtocol?: boolean;
sendProxyProtocol?: boolean;
defaults?: IRustDefaultConfig;
connectionTimeout?: number;
initialDataTimeout?: number;
socketTimeout?: number;
inactivityCheckInterval?: number;
maxConnectionLifetime?: number;
inactivityTimeout?: number;
gracefulShutdownTimeout?: number;
noDelay?: boolean;
keepAlive?: boolean;
keepAliveInitialDelay?: number;
maxPendingDataSize?: number;
disableInactivityCheck?: boolean;
enableKeepAliveProbes?: boolean;
enableDetailedLogging?: boolean;
enableTlsDebugLogging?: boolean;
enableRandomizedTimeouts?: boolean;
maxConnectionsPerIp?: number;
connectionRateLimitPerMinute?: number;
keepAliveTreatment?: ISmartProxyOptions['keepAliveTreatment'];
keepAliveInactivityMultiplier?: number;
extendedKeepAliveLifetime?: number;
metrics?: ISmartProxyOptions['metrics'];
acme?: IRustAcmeOptions;
}
export interface IRustStatistics {
activeConnections: number;
totalConnections: number;
routesCount: number;
listeningPorts: number[];
uptimeSeconds: number;
}
export interface IRustCertificateStatus {
domain: string;
source: string;
expiresAt: number;
isValid: boolean;
}
export interface IRustThroughputSample {
timestampMs: number;
bytesIn: number;
bytesOut: number;
}
export interface IRustRouteMetrics {
activeConnections: number;
totalConnections: number;
bytesIn: number;
bytesOut: number;
throughputInBytesPerSec: number;
throughputOutBytesPerSec: number;
throughputRecentInBytesPerSec: number;
throughputRecentOutBytesPerSec: number;
}
export interface IRustIpMetrics {
activeConnections: number;
totalConnections: number;
bytesIn: number;
bytesOut: number;
throughputInBytesPerSec: number;
throughputOutBytesPerSec: number;
domainRequests: Record<string, number>;
}
export interface IRustBackendMetrics {
activeConnections: number;
totalConnections: number;
protocol: string;
connectErrors: number;
handshakeErrors: number;
requestErrors: number;
totalConnectTimeUs: number;
connectCount: number;
poolHits: number;
poolMisses: number;
h2Failures: number;
}
export interface IRustMetricsSnapshot {
activeConnections: number;
totalConnections: number;
bytesIn: number;
bytesOut: number;
throughputInBytesPerSec: number;
throughputOutBytesPerSec: number;
throughputRecentInBytesPerSec: number;
throughputRecentOutBytesPerSec: number;
routes: Record<string, IRustRouteMetrics>;
ips: Record<string, IRustIpMetrics>;
backends: Record<string, IRustBackendMetrics>;
throughputHistory: IRustThroughputSample[];
totalHttpRequests: number;
httpRequestsPerSec: number;
httpRequestsPerSecRecent: number;
activeUdpSessions: number;
totalUdpSessions: number;
totalDatagramsIn: number;
totalDatagramsOut: number;
detectedProtocols: IProtocolCacheEntry[];
frontendProtocols: IProtocolDistribution;
backendProtocols: IProtocolDistribution;
}
+13 -11
View File
@@ -1,5 +1,6 @@
import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js';
import { logger } from '../../core/utils/logger.js';
import type { IRustRouteConfig } from './models/rust-types.js';
import { serializeRouteForRust } from './utils/rust-config.js';
/**
* Preprocesses routes before sending them to Rust.
@@ -24,7 +25,7 @@ export class RoutePreprocessor {
* - Non-serializable fields are stripped
* - Original routes are preserved in the local map for handler lookup
*/
public preprocessForRust(routes: IRouteConfig[]): IRouteConfig[] {
public preprocessForRust(routes: IRouteConfig[]): IRustRouteConfig[] {
this.originalRoutes.clear();
return routes.map((route, index) => this.preprocessRoute(route, index));
}
@@ -43,7 +44,7 @@ export class RoutePreprocessor {
return new Map(this.originalRoutes);
}
private preprocessRoute(route: IRouteConfig, index: number): IRouteConfig {
private preprocessRoute(route: IRouteConfig, index: number): IRustRouteConfig {
const routeKey = route.name || route.id || `route_${index}`;
// Check if this route needs TS-side handling
@@ -57,7 +58,7 @@ export class RoutePreprocessor {
// Create a clean copy for Rust
const cleanRoute: IRouteConfig = {
...route,
action: this.cleanAction(route.action, routeKey, needsTsHandling),
action: this.cleanAction(route.action, needsTsHandling),
};
// Ensure we have a name for handler lookup
@@ -65,7 +66,7 @@ export class RoutePreprocessor {
cleanRoute.name = routeKey;
}
return cleanRoute;
return serializeRouteForRust(cleanRoute);
}
private routeNeedsTsHandling(route: IRouteConfig): boolean {
@@ -91,15 +92,16 @@ export class RoutePreprocessor {
return false;
}
private cleanAction(action: IRouteAction, routeKey: string, needsTsHandling: boolean): IRouteAction {
const cleanAction: IRouteAction = { ...action };
private cleanAction(action: IRouteAction, needsTsHandling: boolean): IRouteAction {
let cleanAction: IRouteAction = { ...action };
if (needsTsHandling) {
// Convert to socket-handler type for Rust (Rust will relay back to TS)
cleanAction.type = 'socket-handler';
// Remove the JS handlers (not serializable)
delete (cleanAction as any).socketHandler;
delete (cleanAction as any).datagramHandler;
const { socketHandler: _socketHandler, datagramHandler: _datagramHandler, ...serializableAction } = cleanAction;
cleanAction = {
...serializableAction,
type: 'socket-handler',
};
}
// Clean targets - replace functions with static values
+95 -33
View File
@@ -1,5 +1,6 @@
import type { IMetrics, IBackendMetrics, IProtocolCacheEntry, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js';
import type { IMetrics, IBackendMetrics, IProtocolCacheEntry, IProtocolDistribution, IThroughputData, IThroughputHistoryPoint } from './models/metrics-types.js';
import type { RustProxyBridge } from './rust-proxy-bridge.js';
import type { IRustBackendMetrics, IRustIpMetrics, IRustMetricsSnapshot, IRustRouteMetrics } from './models/rust-types.js';
/**
* Adapts Rust JSON metrics to the IMetrics interface.
@@ -14,7 +15,7 @@ import type { RustProxyBridge } from './rust-proxy-bridge.js';
*/
export class RustMetricsAdapter implements IMetrics {
private bridge: RustProxyBridge;
private cache: any = null;
private cache: IRustMetricsSnapshot | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private pollIntervalMs: number;
@@ -65,8 +66,8 @@ export class RustMetricsAdapter implements IMetrics {
byRoute: (): Map<string, number> => {
const result = new Map<string, number>();
if (this.cache?.routes) {
for (const [name, rm] of Object.entries(this.cache.routes)) {
result.set(name, (rm as any).activeConnections ?? 0);
for (const [name, rm] of Object.entries(this.cache.routes) as Array<[string, IRustRouteMetrics]>) {
result.set(name, rm.activeConnections ?? 0);
}
}
return result;
@@ -74,8 +75,8 @@ export class RustMetricsAdapter implements IMetrics {
byIP: (): Map<string, number> => {
const result = new Map<string, number>();
if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips)) {
result.set(ip, (im as any).activeConnections ?? 0);
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
result.set(ip, im.activeConnections ?? 0);
}
}
return result;
@@ -83,13 +84,76 @@ export class RustMetricsAdapter implements IMetrics {
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
const result: Array<{ ip: string; count: number }> = [];
if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips)) {
result.push({ ip, count: (im as any).activeConnections ?? 0 });
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
result.push({ ip, count: im.activeConnections ?? 0 });
}
}
result.sort((a, b) => b.count - a.count);
return result.slice(0, limit);
},
domainRequestsByIP: (): Map<string, Map<string, number>> => {
const result = new Map<string, Map<string, number>>();
if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
const dr = im.domainRequests;
if (dr && typeof dr === 'object') {
const domainMap = new Map<string, number>();
for (const [domain, count] of Object.entries(dr)) {
domainMap.set(domain, count as number);
}
if (domainMap.size > 0) {
result.set(ip, domainMap);
}
}
}
}
return result;
},
topDomainRequests: (limit: number = 20): Array<{ ip: string; domain: string; count: number }> => {
const result: Array<{ ip: string; domain: string; count: number }> = [];
if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
const dr = im.domainRequests;
if (dr && typeof dr === 'object') {
for (const [domain, count] of Object.entries(dr)) {
result.push({ ip, domain, count: count as number });
}
}
}
}
result.sort((a, b) => b.count - a.count);
return result.slice(0, limit);
},
frontendProtocols: (): IProtocolDistribution => {
const fp = this.cache?.frontendProtocols;
return {
h1Active: fp?.h1Active ?? 0,
h1Total: fp?.h1Total ?? 0,
h2Active: fp?.h2Active ?? 0,
h2Total: fp?.h2Total ?? 0,
h3Active: fp?.h3Active ?? 0,
h3Total: fp?.h3Total ?? 0,
wsActive: fp?.wsActive ?? 0,
wsTotal: fp?.wsTotal ?? 0,
otherActive: fp?.otherActive ?? 0,
otherTotal: fp?.otherTotal ?? 0,
};
},
backendProtocols: (): IProtocolDistribution => {
const bp = this.cache?.backendProtocols;
return {
h1Active: bp?.h1Active ?? 0,
h1Total: bp?.h1Total ?? 0,
h2Active: bp?.h2Active ?? 0,
h2Total: bp?.h2Total ?? 0,
h3Active: bp?.h3Active ?? 0,
h3Total: bp?.h3Total ?? 0,
wsActive: bp?.wsActive ?? 0,
wsTotal: bp?.wsTotal ?? 0,
otherActive: bp?.otherActive ?? 0,
otherTotal: bp?.otherTotal ?? 0,
};
},
};
public throughput = {
@@ -113,7 +177,7 @@ export class RustMetricsAdapter implements IMetrics {
},
history: (seconds: number): Array<IThroughputHistoryPoint> => {
if (!this.cache?.throughputHistory) return [];
return this.cache.throughputHistory.slice(-seconds).map((p: any) => ({
return this.cache.throughputHistory.slice(-seconds).map((p) => ({
timestamp: p.timestampMs,
in: p.bytesIn,
out: p.bytesOut,
@@ -122,10 +186,10 @@ export class RustMetricsAdapter implements IMetrics {
byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => {
const result = new Map<string, IThroughputData>();
if (this.cache?.routes) {
for (const [name, rm] of Object.entries(this.cache.routes)) {
for (const [name, rm] of Object.entries(this.cache.routes) as Array<[string, IRustRouteMetrics]>) {
result.set(name, {
in: (rm as any).throughputInBytesPerSec ?? 0,
out: (rm as any).throughputOutBytesPerSec ?? 0,
in: rm.throughputInBytesPerSec ?? 0,
out: rm.throughputOutBytesPerSec ?? 0,
});
}
}
@@ -134,10 +198,10 @@ export class RustMetricsAdapter implements IMetrics {
byIP: (_windowSeconds?: number): Map<string, IThroughputData> => {
const result = new Map<string, IThroughputData>();
if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips)) {
for (const [ip, im] of Object.entries(this.cache.ips) as Array<[string, IRustIpMetrics]>) {
result.set(ip, {
in: (im as any).throughputInBytesPerSec ?? 0,
out: (im as any).throughputOutBytesPerSec ?? 0,
in: im.throughputInBytesPerSec ?? 0,
out: im.throughputOutBytesPerSec ?? 0,
});
}
}
@@ -173,23 +237,22 @@ export class RustMetricsAdapter implements IMetrics {
byBackend: (): Map<string, IBackendMetrics> => {
const result = new Map<string, IBackendMetrics>();
if (this.cache?.backends) {
for (const [key, bm] of Object.entries(this.cache.backends)) {
const m = bm as any;
const totalTimeUs = m.totalConnectTimeUs ?? 0;
const count = m.connectCount ?? 0;
const poolHits = m.poolHits ?? 0;
const poolMisses = m.poolMisses ?? 0;
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
const totalTimeUs = bm.totalConnectTimeUs ?? 0;
const count = bm.connectCount ?? 0;
const poolHits = bm.poolHits ?? 0;
const poolMisses = bm.poolMisses ?? 0;
const poolTotal = poolHits + poolMisses;
result.set(key, {
protocol: m.protocol ?? 'unknown',
activeConnections: m.activeConnections ?? 0,
totalConnections: m.totalConnections ?? 0,
connectErrors: m.connectErrors ?? 0,
handshakeErrors: m.handshakeErrors ?? 0,
requestErrors: m.requestErrors ?? 0,
protocol: bm.protocol ?? 'unknown',
activeConnections: bm.activeConnections ?? 0,
totalConnections: bm.totalConnections ?? 0,
connectErrors: bm.connectErrors ?? 0,
handshakeErrors: bm.handshakeErrors ?? 0,
requestErrors: bm.requestErrors ?? 0,
avgConnectTimeMs: count > 0 ? (totalTimeUs / count) / 1000 : 0,
poolHitRate: poolTotal > 0 ? poolHits / poolTotal : 0,
h2Failures: m.h2Failures ?? 0,
h2Failures: bm.h2Failures ?? 0,
});
}
}
@@ -198,8 +261,8 @@ export class RustMetricsAdapter implements IMetrics {
protocols: (): Map<string, string> => {
const result = new Map<string, string>();
if (this.cache?.backends) {
for (const [key, bm] of Object.entries(this.cache.backends)) {
result.set(key, (bm as any).protocol ?? 'unknown');
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
result.set(key, bm.protocol ?? 'unknown');
}
}
return result;
@@ -207,9 +270,8 @@ export class RustMetricsAdapter implements IMetrics {
topByErrors: (limit: number = 10): Array<{ backend: string; errors: number }> => {
const result: Array<{ backend: string; errors: number }> = [];
if (this.cache?.backends) {
for (const [key, bm] of Object.entries(this.cache.backends)) {
const m = bm as any;
const errors = (m.connectErrors ?? 0) + (m.handshakeErrors ?? 0) + (m.requestErrors ?? 0);
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
const errors = (bm.connectErrors ?? 0) + (bm.handshakeErrors ?? 0) + (bm.requestErrors ?? 0);
if (errors > 0) result.push({ backend: key, errors });
}
}
+24 -18
View File
@@ -1,23 +1,29 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.js';
import type { IRouteConfig } from './models/route-types.js';
import type {
IRustCertificateStatus,
IRustMetricsSnapshot,
IRustProxyOptions,
IRustRouteConfig,
IRustStatistics,
} from './models/rust-types.js';
/**
* Type-safe command definitions for the Rust proxy IPC protocol.
*/
type TSmartProxyCommands = {
start: { params: { config: any }; result: void };
stop: { params: Record<string, never>; result: void };
updateRoutes: { params: { routes: IRouteConfig[] }; result: void };
getMetrics: { params: Record<string, never>; result: any };
getStatistics: { params: Record<string, never>; result: any };
provisionCertificate: { params: { routeName: string }; result: void };
renewCertificate: { params: { routeName: string }; result: void };
getCertificateStatus: { params: { routeName: string }; result: any };
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
addListeningPort: { params: { port: number }; result: void };
removeListeningPort: { params: { port: number }; result: void };
start: { params: { config: IRustProxyOptions }; result: void };
stop: { params: Record<string, never>; result: void };
updateRoutes: { params: { routes: IRustRouteConfig[] }; result: void };
getMetrics: { params: Record<string, never>; result: IRustMetricsSnapshot };
getStatistics: { params: Record<string, never>; result: IRustStatistics };
provisionCertificate: { params: { routeName: string }; result: void };
renewCertificate: { params: { routeName: string }; result: void };
getCertificateStatus: { params: { routeName: string }; result: IRustCertificateStatus | null };
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
addListeningPort: { params: { port: number }; result: void };
removeListeningPort: { params: { port: number }; result: void };
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
setDatagramHandlerRelay: { params: { socketPath: string }; result: void };
};
@@ -121,7 +127,7 @@ export class RustProxyBridge extends plugins.EventEmitter {
// --- Convenience methods for each management command ---
public async startProxy(config: any): Promise<void> {
public async startProxy(config: IRustProxyOptions): Promise<void> {
await this.bridge.sendCommand('start', { config });
}
@@ -129,15 +135,15 @@ export class RustProxyBridge extends plugins.EventEmitter {
await this.bridge.sendCommand('stop', {} as Record<string, never>);
}
public async updateRoutes(routes: IRouteConfig[]): Promise<void> {
public async updateRoutes(routes: IRustRouteConfig[]): Promise<void> {
await this.bridge.sendCommand('updateRoutes', { routes });
}
public async getMetrics(): Promise<any> {
public async getMetrics(): Promise<IRustMetricsSnapshot> {
return this.bridge.sendCommand('getMetrics', {} as Record<string, never>);
}
public async getStatistics(): Promise<any> {
public async getStatistics(): Promise<IRustStatistics> {
return this.bridge.sendCommand('getStatistics', {} as Record<string, never>);
}
@@ -149,7 +155,7 @@ export class RustProxyBridge extends plugins.EventEmitter {
await this.bridge.sendCommand('renewCertificate', { routeName });
}
public async getCertificateStatus(routeName: string): Promise<any> {
public async getCertificateStatus(routeName: string): Promise<IRustCertificateStatus | null> {
return this.bridge.sendCommand('getCertificateStatus', { routeName });
}
+6 -33
View File
@@ -11,6 +11,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
// Route management
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { RouteValidator } from './utils/route-validator.js';
import { buildRustProxyOptions } from './utils/rust-config.js';
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
import { Mutex } from './utils/mutex.js';
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
@@ -19,6 +20,7 @@ import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js';
import type { IMetrics } from './models/metrics-types.js';
import type { IRustCertificateStatus, IRustProxyOptions, IRustStatistics } from './models/rust-types.js';
/**
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
@@ -365,7 +367,7 @@ export class SmartProxy extends plugins.EventEmitter {
/**
* Get certificate status for a route (async - calls Rust).
*/
public async getCertificateStatus(routeName: string): Promise<any> {
public async getCertificateStatus(routeName: string): Promise<IRustCertificateStatus | null> {
return this.bridge.getCertificateStatus(routeName);
}
@@ -379,7 +381,7 @@ export class SmartProxy extends plugins.EventEmitter {
/**
* Get statistics (async - calls Rust).
*/
public async getStatistics(): Promise<any> {
public async getStatistics(): Promise<IRustStatistics> {
return this.bridge.getStatistics();
}
@@ -484,37 +486,8 @@ export class SmartProxy extends plugins.EventEmitter {
/**
* Build the Rust configuration object from TS settings.
*/
private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any {
const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme;
return {
routes,
defaults: this.settings.defaults,
acme: acme
? {
enabled: acme.enabled,
email: acme.email,
useProduction: acme.useProduction,
port: acme.port,
renewThresholdDays: acme.renewThresholdDays,
autoRenew: acme.autoRenew,
renewCheckIntervalHours: acme.renewCheckIntervalHours,
}
: undefined,
connectionTimeout: this.settings.connectionTimeout,
initialDataTimeout: this.settings.initialDataTimeout,
socketTimeout: this.settings.socketTimeout,
maxConnectionLifetime: this.settings.maxConnectionLifetime,
gracefulShutdownTimeout: this.settings.gracefulShutdownTimeout,
maxConnectionsPerIp: this.settings.maxConnectionsPerIP,
connectionRateLimitPerMinute: this.settings.connectionRateLimitPerMinute,
keepAliveTreatment: this.settings.keepAliveTreatment,
keepAliveInactivityMultiplier: this.settings.keepAliveInactivityMultiplier,
extendedKeepAliveLifetime: this.settings.extendedKeepAliveLifetime,
proxyIps: this.settings.proxyIPs,
acceptProxyProtocol: this.settings.acceptProxyProtocol,
sendProxyProtocol: this.settings.sendProxyProtocol,
metrics: this.settings.metrics,
};
private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions {
return buildRustProxyOptions(this.settings, routes, acmeOverride);
}
/**
+3 -11
View File
@@ -2,12 +2,9 @@
* SmartProxy Route Utilities
*
* This file exports all route-related utilities for the SmartProxy module,
* including helpers, validators, utilities, and patterns for working with routes.
* including validators, utilities, and socket handlers for working with routes.
*/
// Export route helpers for creating route configurations
export * from './route-helpers.js';
// Export route validator (class-based and functional API)
export * from './route-validator.js';
@@ -20,10 +17,5 @@ export { generateDefaultCertificate } from './default-cert-generator.js';
// Export concurrency semaphore
export { ConcurrencySemaphore } from './concurrency-semaphore.js';
// Export additional functions from route-helpers that weren't already exported
export {
createApiGatewayRoute,
addRateLimiting,
addBasicAuth,
addJwtAuth
} from './route-helpers.js';
// Export socket handlers
export { SocketHandlers } from './socket-handlers.js';
@@ -1,11 +0,0 @@
/**
* Route Helper Functions
*
* This file re-exports all route helper functions for backwards compatibility.
* The actual implementations have been split into focused modules in the route-helpers/ directory.
*
* @see ./route-helpers/index.ts for the modular exports
*/
// Re-export everything from the modular helpers
export * from './route-helpers/index.js';
@@ -1,144 +0,0 @@
/**
* API Route Helper Functions
*
* This module provides utility functions for creating API route configurations.
*/
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
import { mergeRouteConfigs } from '../route-utils.js';
import { createHttpRoute } from './http-helpers.js';
import { createHttpsTerminateRoute } from './https-helpers.js';
/**
* Create an API route configuration
* @param domains Domain(s) to match
* @param apiPath API base path (e.g., "/api")
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createApiRoute(
domains: string | string[],
apiPath: string,
target: { host: string | string[]; port: number },
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
addCorsHeaders?: boolean;
httpPort?: number | number[];
httpsPort?: number | number[];
name?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Normalize API path
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
const pathWithWildcard = normalizedPath.endsWith('/')
? `${normalizedPath}*`
: `${normalizedPath}/*`;
// Create route match
const match: IRouteMatch = {
ports: options.useTls
? (options.httpsPort || 443)
: (options.httpPort || 80),
domains,
path: pathWithWildcard
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [target]
};
// Add TLS configuration if using HTTPS
if (options.useTls) {
action.tls = {
mode: 'terminate',
certificate: options.certificate || 'auto'
};
}
// Add CORS headers if requested
const headers: Record<string, Record<string, string>> = {};
if (options.addCorsHeaders) {
headers.response = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
};
}
// Create the route config
return {
match,
action,
headers: Object.keys(headers).length > 0 ? headers : undefined,
name: options.name || `API Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
priority: options.priority || 100, // Higher priority for specific path matches
...options
};
}
/**
* Create an API Gateway route pattern
* @param domains Domain(s) to match
* @param apiBasePath Base path for API endpoints (e.g., '/api')
* @param target Target host and port
* @param options Additional route options
* @returns API route configuration
*/
export function createApiGatewayRoute(
domains: string | string[],
apiBasePath: string,
target: { host: string | string[]; port: number },
options: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
addCorsHeaders?: boolean;
[key: string]: any;
} = {}
): IRouteConfig {
// Normalize apiBasePath to ensure it starts with / and doesn't end with /
const normalizedPath = apiBasePath.startsWith('/')
? apiBasePath
: `/${apiBasePath}`;
// Add wildcard to path to match all API endpoints
const apiPath = normalizedPath.endsWith('/')
? `${normalizedPath}*`
: `${normalizedPath}/*`;
// Create base route
const baseRoute = options.useTls
? createHttpsTerminateRoute(domains, target, {
certificate: options.certificate || 'auto'
})
: createHttpRoute(domains, target);
// Add API-specific configurations
const apiRoute: Partial<IRouteConfig> = {
match: {
...baseRoute.match,
path: apiPath
},
name: options.name || `API Gateway: ${apiPath} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`,
priority: options.priority || 100 // Higher priority for specific path matching
};
// Add CORS headers if requested
if (options.addCorsHeaders) {
apiRoute.headers = {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
};
}
return mergeRouteConfigs(baseRoute, apiRoute);
}
@@ -1,125 +0,0 @@
/**
* Dynamic Route Helper Functions
*
* This module provides utility functions for creating dynamic routes
* with context-based host and port mapping.
*/
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange, IRouteContext } from '../../models/route-types.js';
/**
* Create a helper function that applies a port offset
* @param offset The offset to apply to the matched port
* @returns A function that adds the offset to the matched port
*/
export function createPortOffset(offset: number): (context: IRouteContext) => number {
return (context: IRouteContext) => context.port + offset;
}
/**
* Create a port mapping route with context-based port function
* @param options Port mapping route options
* @returns Route configuration object
*/
export function createPortMappingRoute(options: {
sourcePortRange: TPortRange;
targetHost: string | string[] | ((context: IRouteContext) => string | string[]);
portMapper: (context: IRouteContext) => number;
name?: string;
domains?: string | string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.sourcePortRange,
domains: options.domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [{
host: options.targetHost,
port: options.portMapper
}]
};
// Create the route config
return {
match,
action,
name: options.name || `Port Mapping Route for ${options.domains || 'all domains'}`,
priority: options.priority,
...options
};
}
/**
* Create a simple offset port mapping route
* @param options Offset port mapping route options
* @returns Route configuration object
*/
export function createOffsetPortMappingRoute(options: {
ports: TPortRange;
targetHost: string | string[];
offset: number;
name?: string;
domains?: string | string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
const { ports, targetHost, offset, name, domains, priority, ...rest } = options;
return createPortMappingRoute({
sourcePortRange: ports,
targetHost,
portMapper: (context) => context.port + offset,
name: name || `Offset Mapping (${offset > 0 ? '+' : ''}${offset}) for ${domains || 'all domains'}`,
domains,
priority,
...rest
});
}
/**
* Create a dynamic route with context-based host and port mapping
* @param options Dynamic route options
* @returns Route configuration object
*/
export function createDynamicRoute(options: {
ports: TPortRange;
targetHost: (context: IRouteContext) => string | string[];
portMapper: (context: IRouteContext) => number;
name?: string;
domains?: string | string[];
path?: string;
clientIp?: string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.ports,
domains: options.domains,
path: options.path,
clientIp: options.clientIp
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [{
host: options.targetHost,
port: options.portMapper
}]
};
// Create the route config
return {
match,
action,
name: options.name || `Dynamic Route for ${options.domains || 'all domains'}`,
priority: options.priority,
...options
};
}
@@ -1,40 +0,0 @@
/**
* HTTP Route Helper Functions
*
* This module provides utility functions for creating HTTP route configurations.
*/
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
/**
* Create an HTTP-only route configuration
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpRoute(
domains: string | string[],
target: { host: string | string[]; port: number },
options: Partial<IRouteConfig> = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.match?.ports || 80,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [target]
};
// Create the route config
return {
match,
action,
name: options.name || `HTTP Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
@@ -1,163 +0,0 @@
/**
* HTTPS Route Helper Functions
*
* This module provides utility functions for creating HTTPS route configurations
* including TLS termination and passthrough routes.
*/
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
import { SocketHandlers } from './socket-handlers.js';
/**
* Create an HTTPS route with TLS termination
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpsTerminateRoute(
domains: string | string[],
target: { host: string | string[]; port: number },
options: {
certificate?: 'auto' | { key: string; cert: string };
httpPort?: number | number[];
httpsPort?: number | number[];
reencrypt?: boolean;
name?: string;
[key: string]: any;
} = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.httpsPort || 443,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [target],
tls: {
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
certificate: options.certificate || 'auto'
}
};
// Create the route config
return {
match,
action,
name: options.name || `HTTPS Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create an HTTP to HTTPS redirect route
* @param domains Domain(s) to match
* @param httpsPort HTTPS port to redirect to (default: 443)
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpToHttpsRedirect(
domains: string | string[],
httpsPort: number = 443,
options: Partial<IRouteConfig> = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.match?.ports || 80,
domains
};
// Create route action
const action: IRouteAction = {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
};
// Create the route config
return {
match,
action,
name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create an HTTPS passthrough route (SNI-based forwarding without TLS termination)
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createHttpsPassthroughRoute(
domains: string | string[],
target: { host: string | string[]; port: number },
options: Partial<IRouteConfig> = {}
): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.match?.ports || 443,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [target],
tls: {
mode: 'passthrough'
}
};
// Create the route config
return {
match,
action,
name: options.name || `HTTPS Passthrough for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...options
};
}
/**
* Create a complete HTTPS server with HTTP to HTTPS redirects
* @param domains Domain(s) to match
* @param target Target host and port
* @param options Additional configuration options
* @returns Array of two route configurations (HTTPS and HTTP redirect)
*/
export function createCompleteHttpsServer(
domains: string | string[],
target: { host: string | string[]; port: number },
options: {
certificate?: 'auto' | { key: string; cert: string };
httpPort?: number | number[];
httpsPort?: number | number[];
reencrypt?: boolean;
name?: string;
[key: string]: any;
} = {}
): IRouteConfig[] {
// Create the HTTPS route
const httpsRoute = createHttpsTerminateRoute(domains, target, options);
// Create the HTTP redirect route
const httpRedirectRoute = createHttpToHttpsRedirect(
domains,
// Extract the HTTPS port from the HTTPS route - ensure it's a number
typeof options.httpsPort === 'number' ? options.httpsPort :
Array.isArray(options.httpsPort) ? options.httpsPort[0] : 443,
{
// Set the HTTP port
match: {
ports: options.httpPort || 80,
domains
},
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`
}
);
return [httpsRoute, httpRedirectRoute];
}
@@ -1,62 +0,0 @@
/**
* Route Helper Functions
*
* This module provides utility functions for creating route configurations for common scenarios.
* These functions aim to simplify the creation of route configurations for typical use cases.
*
* This barrel file re-exports all helper functions for backwards compatibility.
*/
// HTTP helpers
export { createHttpRoute } from './http-helpers.js';
// HTTPS helpers
export {
createHttpsTerminateRoute,
createHttpToHttpsRedirect,
createHttpsPassthroughRoute,
createCompleteHttpsServer
} from './https-helpers.js';
// WebSocket helpers
export { createWebSocketRoute } from './websocket-helpers.js';
// Load balancer helpers
export {
createLoadBalancerRoute,
createSmartLoadBalancer
} from './load-balancer-helpers.js';
// NFTables helpers
export {
createNfTablesRoute,
createNfTablesTerminateRoute,
createCompleteNfTablesHttpsServer
} from './nftables-helpers.js';
// Dynamic routing helpers
export {
createPortOffset,
createPortMappingRoute,
createOffsetPortMappingRoute,
createDynamicRoute
} from './dynamic-helpers.js';
// API helpers
export {
createApiRoute,
createApiGatewayRoute
} from './api-helpers.js';
// Security helpers
export {
addRateLimiting,
addBasicAuth,
addJwtAuth
} from './security-helpers.js';
// Socket handlers
export {
SocketHandlers,
createSocketHandlerRoute
} from './socket-handlers.js';
@@ -1,154 +0,0 @@
/**
* Load Balancer Route Helper Functions
*
* This module provides utility functions for creating load balancer route configurations.
*/
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../../models/route-types.js';
/**
* Create a load balancer route (round-robin between multiple backend hosts)
* @param domains Domain(s) to match
* @param backendsOrHosts Array of backend servers OR array of host strings (legacy)
* @param portOrOptions Port number (legacy) OR options object
* @param options Additional route options (legacy)
* @returns Route configuration object
*/
export function createLoadBalancerRoute(
domains: string | string[],
backendsOrHosts: Array<{ host: string; port: number }> | string[],
portOrOptions?: number | {
tls?: {
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
certificate?: 'auto' | { key: string; cert: string };
};
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
algorithm?: 'round-robin' | 'least-connections' | 'ip-hash';
healthCheck?: {
path: string;
interval: number;
timeout: number;
unhealthyThreshold: number;
healthyThreshold: number;
};
[key: string]: any;
},
options?: {
tls?: {
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
certificate?: 'auto' | { key: string; cert: string };
};
[key: string]: any;
}
): IRouteConfig {
// Handle legacy signature: (domains, hosts[], port, options)
let backends: Array<{ host: string; port: number }>;
let finalOptions: any;
if (Array.isArray(backendsOrHosts) && backendsOrHosts.length > 0 && typeof backendsOrHosts[0] === 'string') {
// Legacy signature
const hosts = backendsOrHosts as string[];
const port = portOrOptions as number;
backends = hosts.map(host => ({ host, port }));
finalOptions = options || {};
} else {
// New signature
backends = backendsOrHosts as Array<{ host: string; port: number }>;
finalOptions = (portOrOptions as any) || {};
}
// Extract hosts and ensure all backends use the same port
const port = backends[0].port;
const hosts = backends.map(backend => backend.host);
// Create route match
const match: IRouteMatch = {
ports: finalOptions.match?.ports || (finalOptions.tls || finalOptions.useTls ? 443 : 80),
domains
};
// Create route target
const target: IRouteTarget = {
host: hosts,
port
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [target]
};
// Add TLS configuration if provided
if (finalOptions.tls || finalOptions.useTls) {
action.tls = {
mode: finalOptions.tls?.mode || 'terminate',
certificate: finalOptions.tls?.certificate || finalOptions.certificate || 'auto'
};
}
// Add load balancing options
if (finalOptions.algorithm || finalOptions.healthCheck) {
action.loadBalancing = {
algorithm: finalOptions.algorithm || 'round-robin',
healthCheck: finalOptions.healthCheck
};
}
// Create the route config
return {
match,
action,
name: finalOptions.name || `Load Balancer for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
...finalOptions
};
}
/**
* Create a smart load balancer with dynamic domain-based backend selection
* @param options Smart load balancer options
* @returns Route configuration object
*/
export function createSmartLoadBalancer(options: {
ports: TPortRange;
domainTargets: Record<string, string | string[]>;
portMapper: (context: IRouteContext) => number;
name?: string;
defaultTarget?: string | string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
// Extract all domain keys to create the match criteria
const domains = Object.keys(options.domainTargets);
// Create the smart host selector function
const hostSelector = (context: IRouteContext) => {
const domain = context.domain || '';
return options.domainTargets[domain] || options.defaultTarget || 'localhost';
};
// Create route match
const match: IRouteMatch = {
ports: options.ports,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [{
host: hostSelector,
port: options.portMapper
}]
};
// Create the route config
return {
match,
action,
name: options.name || `Smart Load Balancer for ${domains.join(', ')}`,
priority: options.priority,
...options
};
}
@@ -1,202 +0,0 @@
/**
* NFTables Route Helper Functions
*
* This module provides utility functions for creating NFTables-based route configurations
* for high-performance packet forwarding at the kernel level.
*/
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../../models/route-types.js';
import { createHttpToHttpsRedirect } from './https-helpers.js';
/**
* Create an NFTables-based route for high-performance packet forwarding
* @param nameOrDomains Name or domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createNfTablesRoute(
nameOrDomains: string | string[],
target: { host: string; port: number | 'preserve' },
options: {
ports?: TPortRange;
protocol?: 'tcp' | 'udp' | 'all';
preserveSourceIP?: boolean;
ipAllowList?: string[];
ipBlockList?: string[];
maxRate?: string;
priority?: number;
useTls?: boolean;
tableName?: string;
useIPSets?: boolean;
useAdvancedNAT?: boolean;
} = {}
): IRouteConfig {
// Determine if this is a name or domain
let name: string;
let domains: string | string[] | undefined;
if (Array.isArray(nameOrDomains) || (typeof nameOrDomains === 'string' && nameOrDomains.includes('.'))) {
domains = nameOrDomains;
name = Array.isArray(nameOrDomains) ? nameOrDomains[0] : nameOrDomains;
} else {
name = nameOrDomains;
domains = undefined; // No domains
}
// Create route match
const match: IRouteMatch = {
domains,
ports: options.ports || 80
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [{
host: target.host,
port: target.port
}],
forwardingEngine: 'nftables',
nftables: {
protocol: options.protocol || 'tcp',
preserveSourceIP: options.preserveSourceIP,
maxRate: options.maxRate,
priority: options.priority,
tableName: options.tableName,
useIPSets: options.useIPSets,
useAdvancedNAT: options.useAdvancedNAT
}
};
// Add TLS options if needed
if (options.useTls) {
action.tls = {
mode: 'passthrough'
};
}
// Create the route config
const routeConfig: IRouteConfig = {
name,
match,
action
};
// Add security if allowed or blocked IPs are specified
if (options.ipAllowList?.length || options.ipBlockList?.length) {
routeConfig.security = {
ipAllowList: options.ipAllowList,
ipBlockList: options.ipBlockList
};
}
return routeConfig;
}
/**
* Create an NFTables-based TLS termination route
* @param nameOrDomains Name or domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Route configuration object
*/
export function createNfTablesTerminateRoute(
nameOrDomains: string | string[],
target: { host: string; port: number | 'preserve' },
options: {
ports?: TPortRange;
protocol?: 'tcp' | 'udp' | 'all';
preserveSourceIP?: boolean;
ipAllowList?: string[];
ipBlockList?: string[];
maxRate?: string;
priority?: number;
tableName?: string;
useIPSets?: boolean;
useAdvancedNAT?: boolean;
certificate?: 'auto' | { key: string; cert: string };
} = {}
): IRouteConfig {
// Create basic NFTables route
const route = createNfTablesRoute(
nameOrDomains,
target,
{
...options,
ports: options.ports || 443,
useTls: false
}
);
// Set TLS termination
route.action.tls = {
mode: 'terminate',
certificate: options.certificate || 'auto'
};
return route;
}
/**
* Create a complete NFTables-based HTTPS setup with HTTP redirect
* @param nameOrDomains Name or domain(s) to match
* @param target Target host and port
* @param options Additional route options
* @returns Array of two route configurations (HTTPS and HTTP redirect)
*/
export function createCompleteNfTablesHttpsServer(
nameOrDomains: string | string[],
target: { host: string; port: number | 'preserve' },
options: {
httpPort?: TPortRange;
httpsPort?: TPortRange;
protocol?: 'tcp' | 'udp' | 'all';
preserveSourceIP?: boolean;
ipAllowList?: string[];
ipBlockList?: string[];
maxRate?: string;
priority?: number;
tableName?: string;
useIPSets?: boolean;
useAdvancedNAT?: boolean;
certificate?: 'auto' | { key: string; cert: string };
} = {}
): IRouteConfig[] {
// Create the HTTPS route using NFTables
const httpsRoute = createNfTablesTerminateRoute(
nameOrDomains,
target,
{
...options,
ports: options.httpsPort || 443
}
);
// Determine the domain(s) for HTTP redirect
const domains = typeof nameOrDomains === 'string' && !nameOrDomains.includes('.')
? undefined
: nameOrDomains;
// Extract the HTTPS port for the redirect destination
const httpsPort = typeof options.httpsPort === 'number'
? options.httpsPort
: Array.isArray(options.httpsPort) && typeof options.httpsPort[0] === 'number'
? options.httpsPort[0]
: 443;
// Create the HTTP redirect route (this uses standard forwarding, not NFTables)
const httpRedirectRoute = createHttpToHttpsRedirect(
domains as any, // Type cast needed since domains can be undefined now
httpsPort,
{
match: {
ports: options.httpPort || 80,
domains: domains as any // Type cast needed since domains can be undefined now
},
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains || 'all domains'}`
}
);
return [httpsRoute, httpRedirectRoute];
}
@@ -1,96 +0,0 @@
/**
* Security Route Helper Functions
*
* This module provides utility functions for adding security features to routes.
*/
import type { IRouteConfig } from '../../models/route-types.js';
import { mergeRouteConfigs } from '../route-utils.js';
/**
* Create a rate limiting route pattern
* @param baseRoute Base route to add rate limiting to
* @param rateLimit Rate limiting configuration
* @returns Route with rate limiting
*/
export function addRateLimiting(
baseRoute: IRouteConfig,
rateLimit: {
maxRequests: number;
window: number; // Time window in seconds
keyBy?: 'ip' | 'path' | 'header';
headerName?: string; // Required if keyBy is 'header'
errorMessage?: string;
}
): IRouteConfig {
return mergeRouteConfigs(baseRoute, {
security: {
rateLimit: {
enabled: true,
maxRequests: rateLimit.maxRequests,
window: rateLimit.window,
keyBy: rateLimit.keyBy || 'ip',
headerName: rateLimit.headerName,
errorMessage: rateLimit.errorMessage || 'Rate limit exceeded. Please try again later.'
}
}
});
}
/**
* Create a basic authentication route pattern
* @param baseRoute Base route to add authentication to
* @param auth Authentication configuration
* @returns Route with basic authentication
*/
export function addBasicAuth(
baseRoute: IRouteConfig,
auth: {
users: Array<{ username: string; password: string }>;
realm?: string;
excludePaths?: string[];
}
): IRouteConfig {
return mergeRouteConfigs(baseRoute, {
security: {
basicAuth: {
enabled: true,
users: auth.users,
realm: auth.realm || 'Restricted Area',
excludePaths: auth.excludePaths || []
}
}
});
}
/**
* Create a JWT authentication route pattern
* @param baseRoute Base route to add JWT authentication to
* @param jwt JWT authentication configuration
* @returns Route with JWT authentication
*/
export function addJwtAuth(
baseRoute: IRouteConfig,
jwt: {
secret: string;
algorithm?: string;
issuer?: string;
audience?: string;
expiresIn?: number; // Time in seconds
excludePaths?: string[];
}
): IRouteConfig {
return mergeRouteConfigs(baseRoute, {
security: {
jwtAuth: {
enabled: true,
secret: jwt.secret,
algorithm: jwt.algorithm || 'HS256',
issuer: jwt.issuer,
audience: jwt.audience,
expiresIn: jwt.expiresIn,
excludePaths: jwt.excludePaths || []
}
}
});
}
@@ -1,98 +0,0 @@
/**
* WebSocket Route Helper Functions
*
* This module provides utility functions for creating WebSocket route configurations.
*/
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
/**
* Create a WebSocket route configuration
* @param domains Domain(s) to match
* @param targetOrPath Target server OR WebSocket path (legacy)
* @param targetOrOptions Target server (legacy) OR options
* @param options Additional route options (legacy)
* @returns Route configuration object
*/
export function createWebSocketRoute(
domains: string | string[],
targetOrPath: { host: string | string[]; port: number } | string,
targetOrOptions?: { host: string | string[]; port: number } | {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
path?: string;
httpPort?: number | number[];
httpsPort?: number | number[];
pingInterval?: number;
pingTimeout?: number;
name?: string;
[key: string]: any;
},
options?: {
useTls?: boolean;
certificate?: 'auto' | { key: string; cert: string };
httpPort?: number | number[];
httpsPort?: number | number[];
pingInterval?: number;
pingTimeout?: number;
name?: string;
[key: string]: any;
}
): IRouteConfig {
// Handle different signatures
let target: { host: string | string[]; port: number };
let wsPath: string;
let finalOptions: any;
if (typeof targetOrPath === 'string') {
// Legacy signature: (domains, path, target, options)
wsPath = targetOrPath;
target = targetOrOptions as { host: string | string[]; port: number };
finalOptions = options || {};
} else {
// New signature: (domains, target, options)
target = targetOrPath;
finalOptions = (targetOrOptions as any) || {};
wsPath = finalOptions.path || '/ws';
}
// Normalize WebSocket path
const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`;
// Create route match
const match: IRouteMatch = {
ports: finalOptions.useTls
? (finalOptions.httpsPort || 443)
: (finalOptions.httpPort || 80),
domains,
path: normalizedPath
};
// Create route action
const action: IRouteAction = {
type: 'forward',
targets: [target],
websocket: {
enabled: true,
pingInterval: finalOptions.pingInterval || 30000, // 30 seconds
pingTimeout: finalOptions.pingTimeout || 5000 // 5 seconds
}
};
// Add TLS configuration if using HTTPS
if (finalOptions.useTls) {
action.tls = {
mode: 'terminate',
certificate: finalOptions.certificate || 'auto'
};
}
// Create the route config
return {
match,
action,
name: finalOptions.name || `WebSocket Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
priority: finalOptions.priority || 100, // Higher priority for WebSocket routes
...finalOptions
};
}
+22 -8
View File
@@ -168,14 +168,28 @@ export function routeMatchesHeaders(
if (!route.match?.headers || Object.keys(route.match.headers).length === 0) {
return true; // No headers specified means it matches any headers
}
// Convert RegExp patterns to strings for HeaderMatcher
const stringHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(route.match.headers)) {
stringHeaders[key] = value instanceof RegExp ? value.source : value;
for (const [headerName, expectedValue] of Object.entries(route.match.headers)) {
const actualKey = Object.keys(headers).find((key) => key.toLowerCase() === headerName.toLowerCase());
const actualValue = actualKey ? headers[actualKey] : undefined;
if (actualValue === undefined) {
return false;
}
if (expectedValue instanceof RegExp) {
if (!expectedValue.test(actualValue)) {
return false;
}
continue;
}
if (!HeaderMatcher.match(expectedValue, actualValue)) {
return false;
}
}
return HeaderMatcher.matchAll(stringHeaders, headers);
return true;
}
/**
@@ -283,4 +297,4 @@ export function generateRouteId(route: IRouteConfig): string {
*/
export function cloneRoute(route: IRouteConfig): IRouteConfig {
return JSON.parse(JSON.stringify(route));
}
}
@@ -196,10 +196,19 @@ export class RouteValidator {
// Validate IP allow/block lists
if (route.security.ipAllowList) {
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
for (const ip of allowList) {
if (!this.isValidIPPattern(ip)) {
errors.push(`Invalid IP pattern in allow list: ${ip}`);
for (const entry of allowList) {
if (typeof entry === 'string') {
if (!this.isValidIPPattern(entry)) {
errors.push(`Invalid IP pattern in allow list: ${entry}`);
}
} else if (entry && typeof entry === 'object') {
if (!this.isValidIPPattern(entry.ip)) {
errors.push(`Invalid IP pattern in domain-scoped allow entry: ${entry.ip}`);
}
if (!Array.isArray(entry.domains) || entry.domains.length === 0) {
errors.push(`Domain-scoped allow entry for ${entry.ip} must have non-empty domains array`);
}
}
}
}
+187
View File
@@ -0,0 +1,187 @@
import type { IAcmeOptions, ISmartProxyOptions } from '../models/interfaces.js';
import type { IRouteAction, IRouteConfig, IRouteMatch, IRouteTarget, ITargetMatch } from '../models/route-types.js';
import type {
IRustAcmeOptions,
IRustDefaultConfig,
IRustProxyOptions,
IRustRouteAction,
IRustRouteConfig,
IRustRouteMatch,
IRustRouteTarget,
IRustTargetMatch,
IRustRouteUdp,
TRustHeaderMatchers,
} from '../models/rust-types.js';
const SUPPORTED_REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g']);
export function serializeHeaderMatchValue(value: string | RegExp): string {
if (typeof value === 'string') {
return value;
}
const unsupportedFlags = Array.from(new Set(value.flags)).filter((flag) => !SUPPORTED_REGEX_FLAGS.has(flag));
if (unsupportedFlags.length > 0) {
throw new Error(
`Header RegExp uses unsupported flags for Rust serialization: ${unsupportedFlags.join(', ')}`
);
}
return `/${value.source}/${value.flags}`;
}
export function serializeHeaderMatchers(headers?: Record<string, string | RegExp>): TRustHeaderMatchers | undefined {
if (!headers) {
return undefined;
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, serializeHeaderMatchValue(value)])
);
}
export function serializeTargetMatchForRust(match?: ITargetMatch): IRustTargetMatch | undefined {
if (!match) {
return undefined;
}
return {
...match,
headers: serializeHeaderMatchers(match.headers),
};
}
export function serializeRouteMatchForRust(match: IRouteMatch): IRustRouteMatch {
return {
...match,
headers: serializeHeaderMatchers(match.headers),
};
}
export function serializeRouteTargetForRust(target: IRouteTarget): IRustRouteTarget {
if (typeof target.host !== 'string' && !Array.isArray(target.host)) {
throw new Error('Route target host must be serialized before sending to Rust');
}
if (typeof target.port !== 'number' && target.port !== 'preserve') {
throw new Error('Route target port must be serialized before sending to Rust');
}
return {
...target,
host: target.host,
port: target.port,
match: serializeTargetMatchForRust(target.match),
};
}
function serializeUdpForRust(udp?: IRouteAction['udp']): IRustRouteUdp | undefined {
if (!udp) {
return undefined;
}
const { maxSessionsPerIP, ...rest } = udp;
return {
...rest,
maxSessionsPerIp: maxSessionsPerIP,
};
}
export function serializeRouteActionForRust(action: IRouteAction): IRustRouteAction {
const {
socketHandler: _socketHandler,
datagramHandler: _datagramHandler,
forwardingEngine: _forwardingEngine,
nftables: _nftables,
targets,
udp,
...rest
} = action;
return {
...rest,
targets: targets?.map((target) => serializeRouteTargetForRust(target)),
udp: serializeUdpForRust(udp),
};
}
export function serializeRouteForRust(route: IRouteConfig): IRustRouteConfig {
return {
...route,
match: serializeRouteMatchForRust(route.match),
action: serializeRouteActionForRust(route.action),
};
}
function serializeAcmeForRust(acme?: IAcmeOptions): IRustAcmeOptions | undefined {
if (!acme) {
return undefined;
}
return {
enabled: acme.enabled,
email: acme.email,
environment: acme.environment,
accountEmail: acme.accountEmail,
port: acme.port,
useProduction: acme.useProduction,
renewThresholdDays: acme.renewThresholdDays,
autoRenew: acme.autoRenew,
skipConfiguredCerts: acme.skipConfiguredCerts,
renewCheckIntervalHours: acme.renewCheckIntervalHours,
};
}
function serializeDefaultsForRust(defaults?: ISmartProxyOptions['defaults']): IRustDefaultConfig | undefined {
if (!defaults) {
return undefined;
}
const { preserveSourceIP, ...rest } = defaults;
return {
...rest,
preserveSourceIp: preserveSourceIP,
};
}
export function buildRustProxyOptions(
settings: ISmartProxyOptions,
routes: IRustRouteConfig[],
acmeOverride?: IAcmeOptions,
): IRustProxyOptions {
const acme = acmeOverride !== undefined ? acmeOverride : settings.acme;
return {
routes,
preserveSourceIp: settings.preserveSourceIP,
proxyIps: settings.proxyIPs,
acceptProxyProtocol: settings.acceptProxyProtocol,
sendProxyProtocol: settings.sendProxyProtocol,
defaults: serializeDefaultsForRust(settings.defaults),
connectionTimeout: settings.connectionTimeout,
initialDataTimeout: settings.initialDataTimeout,
socketTimeout: settings.socketTimeout,
inactivityCheckInterval: settings.inactivityCheckInterval,
maxConnectionLifetime: settings.maxConnectionLifetime,
inactivityTimeout: settings.inactivityTimeout,
gracefulShutdownTimeout: settings.gracefulShutdownTimeout,
noDelay: settings.noDelay,
keepAlive: settings.keepAlive,
keepAliveInitialDelay: settings.keepAliveInitialDelay,
maxPendingDataSize: settings.maxPendingDataSize,
disableInactivityCheck: settings.disableInactivityCheck,
enableKeepAliveProbes: settings.enableKeepAliveProbes,
enableDetailedLogging: settings.enableDetailedLogging,
enableTlsDebugLogging: settings.enableTlsDebugLogging,
enableRandomizedTimeouts: settings.enableRandomizedTimeouts,
maxConnectionsPerIp: settings.maxConnectionsPerIP,
connectionRateLimitPerMinute: settings.connectionRateLimitPerMinute,
keepAliveTreatment: settings.keepAliveTreatment,
keepAliveInactivityMultiplier: settings.keepAliveInactivityMultiplier,
extendedKeepAliveLifetime: settings.extendedKeepAliveLifetime,
metrics: settings.metrics,
acme: serializeAcmeForRust(acme),
};
}
@@ -5,9 +5,9 @@
* like echoing, proxying, HTTP responses, and redirects.
*/
import * as plugins from '../../../../plugins.js';
import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js';
import { createSocketTracker } from '../../../../core/utils/socket-tracker.js';
import * as plugins from '../../../plugins.js';
import type { IRouteContext } from '../models/route-types.js';
import { createSocketTracker } from '../../../core/utils/socket-tracker.js';
/**
* Minimal HTTP request parser for socket handlers.
@@ -308,31 +308,3 @@ export const SocketHandlers = {
});
}
};
/**
* Create a socket handler route configuration
*/
export function createSocketHandlerRoute(
domains: string | string[],
ports: TPortRange,
handler: (socket: plugins.net.Socket) => void | Promise<void>,
options: {
name?: string;
priority?: number;
path?: string;
} = {}
): IRouteConfig {
return {
name: options.name || 'socket-handler-route',
priority: options.priority !== undefined ? options.priority : 50,
match: {
domains,
ports,
...(options.path && { path: options.path })
},
action: {
type: 'socket-handler',
socketHandler: handler
}
};
}
+1 -4
View File
@@ -1,13 +1,10 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"ignoreDeprecations": "6.0"
"verbatimModuleSyntax": true
},
"exclude": [
"dist_*/**/*.d.ts"