Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee7237357 | |||
| b5b4c608f0 | |||
| af132f40fc | |||
| 781634446a | |||
| e988d935b6 | |||
| 99a026627d | |||
| 572e31587a | |||
| 8587fb997c | |||
| 9ba101c59b | |||
| 1ad3e61c15 | |||
| 3bfa451341 | |||
| 7b3ab7378b | |||
| 527c616cd4 | |||
| b04eb0ab17 | |||
| a55ff20391 | |||
| 3c24bf659b | |||
| 5be93c8d38 | |||
| 788ccea81e | |||
| 47140e5403 | |||
| a6ffa24e36 | |||
| c0e432fd9b | |||
| a3d8a3a388 | |||
| 437d1a3329 | |||
| 746d93663d | |||
| a3f3fee253 | |||
| 53dee1fffc | |||
| 34dc0cb9b6 | |||
| c83c43194b | |||
| d026d7c266 | |||
| 3b01144c51 | |||
| 56f5697e1b | |||
| f04875885f |
+115
@@ -1,5 +1,120 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- add @push.rocks/smartnftables as a runtime dependency and export it via the plugin layer
|
||||||
|
- remove the internal rustproxy-nftables crate along with Rust-side NFTables rule application and status management
|
||||||
|
- apply and clean up NFTables port-forwarding rules in the TypeScript SmartProxy lifecycle and route update flow
|
||||||
|
- change getNfTablesStatus to return local smartnftables status instead of querying the Rust bridge
|
||||||
|
- update README documentation to describe NFTables support as provided through @push.rocks/smartnftables
|
||||||
|
|
||||||
|
## 2026-03-26 - 26.2.4 - fix(rustproxy-http)
|
||||||
|
improve HTTP/3 connection reuse and clean up stale proxy state
|
||||||
|
|
||||||
|
- Reuse pooled HTTP/3 SendRequest handles to skip repeated SETTINGS handshakes and reduce request overhead on QUIC pool hits
|
||||||
|
- Add periodic cleanup for per-route rate limiters and orphaned backend metrics to prevent unbounded memory growth after traffic or backend errors stop
|
||||||
|
- Enforce HTTP max connection lifetime alongside idle timeouts and apply configured lifetime values from the TCP listener
|
||||||
|
- Reduce HTTP/3 body copying by using owned Bytes paths for request and response streaming, and replace the custom response body adapter with a stream-based implementation
|
||||||
|
- Harden auxiliary proxy components by capping datagram handler buffer growth and removing duplicate RustProxy exit listeners
|
||||||
|
|
||||||
|
## 2026-03-25 - 26.2.3 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-03-25 - 26.2.2 - fix(proxy)
|
||||||
|
improve connection cleanup and route validation handling
|
||||||
|
|
||||||
|
- add timeouts for HTTP/1 upstream connection drivers to prevent lingering tasks
|
||||||
|
- ensure QUIC relay sessions cancel and abort background tasks on drop
|
||||||
|
- avoid registering unnamed routes as duplicates and label unnamed catch-all conflicts clearly
|
||||||
|
- fix offset mapping route helper to forward only remaining route options without overriding derived values
|
||||||
|
- update project config filename and toolchain versions for the current build setup
|
||||||
|
|
||||||
|
## 2026-03-23 - 26.2.1 - fix(rustproxy-http)
|
||||||
|
include the upstream request URL when caching H3 Alt-Svc discoveries
|
||||||
|
|
||||||
|
- Tracks the request path that triggered Alt-Svc discovery in connection activity state
|
||||||
|
- Adds request URL context to Alt-Svc debug logging and protocol cache insertion reasons for better traceability
|
||||||
|
|
||||||
|
## 2026-03-23 - 26.2.0 - feat(protocol-cache)
|
||||||
|
add sliding TTL re-probing and eviction for backend protocol detection
|
||||||
|
|
||||||
|
- extend protocol cache entries and metrics with last accessed and last probed timestamps
|
||||||
|
- trigger periodic ALPN re-probes for cached H1/H2 entries while keeping active entries alive with a sliding 1 day TTL
|
||||||
|
- log protocol transitions with reasons and evict cache entries when all protocol fallback attempts fail
|
||||||
|
|
||||||
|
## 2026-03-22 - 26.1.0 - feat(rustproxy-http)
|
||||||
|
add protocol failure suppression, h3 fallback escalation, and protocol cache metrics exposure
|
||||||
|
|
||||||
|
- introduces escalating cooldowns for failed H2/H3 protocol detection to prevent repeated upgrades to unstable backends
|
||||||
|
- adds within-request escalation to cached HTTP/3 when TCP or TLS backend connections fail in auto-detect mode
|
||||||
|
- exposes detected protocol cache entries and suppression state through Rust metrics and the TypeScript metrics adapter
|
||||||
|
|
||||||
## 2026-03-21 - 26.0.0 - BREAKING CHANGE(ts-api,rustproxy)
|
## 2026-03-21 - 26.0.0 - BREAKING CHANGE(ts-api,rustproxy)
|
||||||
remove deprecated TypeScript protocol and utility exports while hardening QUIC, HTTP/3, WebSocket, and rate limiter cleanup paths
|
remove deprecated TypeScript protocol and utility exports while hardening QUIC, HTTP/3, WebSocket, and rate limiter cleanup paths
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2019 Lossless GmbH (hello@lossless.com)
|
Copyright (c) 2019 Task Venture Capital GmbH (hello@task.vc)
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
+12
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "26.0.0",
|
"version": "27.7.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@@ -16,18 +16,21 @@
|
|||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.3.0",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tsrust": "^1.3.0",
|
"@git.zone/tsrust": "^1.3.2",
|
||||||
"@git.zone/tstest": "^3.5.0",
|
"@git.zone/tstest": "^3.6.0",
|
||||||
"@push.rocks/smartserve": "^2.0.1",
|
"@push.rocks/smartserve": "^2.0.3",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"typescript": "^5.9.3",
|
"@types/ws": "^8.18.1",
|
||||||
"why-is-node-running": "^3.2.2"
|
"typescript": "^6.0.2",
|
||||||
|
"why-is-node-running": "^3.2.2",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartcrypto": "^2.0.4",
|
"@push.rocks/smartcrypto": "^2.0.4",
|
||||||
"@push.rocks/smartlog": "^3.2.1",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
|
"@push.rocks/smartnftables": "^1.0.1",
|
||||||
"@push.rocks/smartrust": "^1.3.2",
|
"@push.rocks/smartrust": "^1.3.2",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
"minimatch": "^10.2.4"
|
"minimatch": "^10.2.4"
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md",
|
"readme.md",
|
||||||
"changelog.md"
|
"changelog.md"
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+286
-220
File diff suppressed because it is too large
Load Diff
+42
-20
@@ -462,35 +462,57 @@ For TLS termination modes (`terminate` and `terminate-and-reencrypt`), SmartProx
|
|||||||
|
|
||||||
**HTTP to HTTPS Redirect**:
|
**HTTP to HTTPS Redirect**:
|
||||||
```typescript
|
```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)**:
|
**Complete HTTPS Server (with redirect)**:
|
||||||
```typescript
|
```typescript
|
||||||
import { createCompleteHttpsServer } from '@push.rocks/smartproxy';
|
const routes = [
|
||||||
|
{
|
||||||
const routes = createCompleteHttpsServer(
|
name: 'https-server',
|
||||||
'example.com',
|
match: { ports: 443, domains: 'example.com' },
|
||||||
{ host: 'localhost', port: 8080 },
|
action: {
|
||||||
{ certificate: 'auto' }
|
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**:
|
**Load Balancer with Health Checks**:
|
||||||
```typescript
|
```typescript
|
||||||
import { createLoadBalancerRoute } from '@push.rocks/smartproxy';
|
const lbRoute = {
|
||||||
|
name: 'load-balancer',
|
||||||
const lbRoute = createLoadBalancerRoute(
|
match: { ports: 443, domains: 'api.example.com' },
|
||||||
'api.example.com',
|
action: {
|
||||||
[
|
type: 'forward' as const,
|
||||||
{ host: 'backend1', port: 8080 },
|
targets: [
|
||||||
{ host: 'backend2', port: 8080 },
|
{ host: 'backend1', port: 8080 },
|
||||||
{ host: 'backend3', port: 8080 }
|
{ host: 'backend2', port: 8080 },
|
||||||
],
|
{ host: 'backend3', port: 8080 }
|
||||||
{ tls: { mode: 'terminate', certificate: 'auto' } }
|
],
|
||||||
);
|
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
|
||||||
|
loadBalancing: { algorithm: 'round-robin' as const }
|
||||||
|
}
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart SNI Requirement (v22.3+)
|
### Smart SNI Requirement (v22.3+)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartproxy 🚀
|
# @push.rocks/smartproxy 🚀
|
||||||
|
|
||||||
**A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding.
|
**A high-performance, Rust-powered proxy toolkit for Node.js** — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables).
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Whether you're building microservices, deploying edge infrastructure, proxying U
|
|||||||
Get up and running in 30 seconds:
|
Get up and running in 30 seconds:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy, createCompleteHttpsServer } from '@push.rocks/smartproxy';
|
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Create a proxy with automatic HTTPS
|
// Create a proxy with automatic HTTPS
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
@@ -53,13 +53,25 @@ const proxy = new SmartProxy({
|
|||||||
useProduction: true
|
useProduction: true
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
// Complete HTTPS setup in one call! ✨
|
// HTTPS route with automatic Let's Encrypt cert
|
||||||
...createCompleteHttpsServer('app.example.com', {
|
{
|
||||||
host: 'localhost',
|
name: 'https-app',
|
||||||
port: 3000
|
match: { ports: 443, domains: 'app.example.com' },
|
||||||
}, {
|
action: {
|
||||||
certificate: 'auto' // Automatic Let's Encrypt cert 🎩
|
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
|
### 🌐 HTTP to HTTPS Redirect
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy, createHttpToHttpsRedirect } from '@push.rocks/smartproxy';
|
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
routes: [
|
routes: [{
|
||||||
createHttpToHttpsRedirect(['example.com', '*.example.com'])
|
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
|
### ⚖️ Load Balancer with Health Checks
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy, createLoadBalancerRoute } from '@push.rocks/smartproxy';
|
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
routes: [
|
routes: [{
|
||||||
createLoadBalancerRoute(
|
name: 'load-balancer',
|
||||||
'app.example.com',
|
match: { ports: 443, domains: 'app.example.com' },
|
||||||
[
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [
|
||||||
{ host: 'server1.internal', port: 8080 },
|
{ host: 'server1.internal', port: 8080 },
|
||||||
{ host: 'server2.internal', port: 8080 },
|
{ host: 'server2.internal', port: 8080 },
|
||||||
{ host: 'server3.internal', port: 8080 }
|
{ host: 'server3.internal', port: 8080 }
|
||||||
],
|
],
|
||||||
{
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
tls: { mode: 'terminate', certificate: 'auto' },
|
loadBalancing: {
|
||||||
algorithm: 'round-robin',
|
algorithm: 'round-robin',
|
||||||
healthCheck: {
|
healthCheck: {
|
||||||
path: '/health',
|
path: '/health',
|
||||||
@@ -145,57 +164,68 @@ const proxy = new SmartProxy({
|
|||||||
healthyThreshold: 2
|
healthyThreshold: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
]
|
}]
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔌 WebSocket Proxy
|
### 🔌 WebSocket Proxy
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy, createWebSocketRoute } from '@push.rocks/smartproxy';
|
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
routes: [
|
routes: [{
|
||||||
createWebSocketRoute(
|
name: 'websocket',
|
||||||
'ws.example.com',
|
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||||
{ host: 'websocket-server', port: 8080 },
|
priority: 100,
|
||||||
{
|
action: {
|
||||||
path: '/socket',
|
type: 'forward',
|
||||||
useTls: true,
|
targets: [{ host: 'websocket-server', port: 8080 }],
|
||||||
certificate: 'auto',
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
websocket: {
|
||||||
|
enabled: true,
|
||||||
pingInterval: 30000,
|
pingInterval: 30000,
|
||||||
pingTimeout: 10000
|
pingTimeout: 10000
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
]
|
}]
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚦 API Gateway with Rate Limiting
|
### 🚦 API Gateway with Rate Limiting
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy, createApiGatewayRoute, addRateLimiting } from '@push.rocks/smartproxy';
|
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
let apiRoute = createApiGatewayRoute(
|
const proxy = new SmartProxy({
|
||||||
'api.example.com',
|
routes: [{
|
||||||
'/api',
|
name: 'api-gateway',
|
||||||
{ host: 'api-backend', port: 8080 },
|
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
|
||||||
{
|
priority: 100,
|
||||||
useTls: true,
|
action: {
|
||||||
certificate: 'auto',
|
type: 'forward',
|
||||||
addCorsHeaders: true
|
targets: [{ host: 'api-backend', port: 8080 }],
|
||||||
}
|
tls: { mode: 'terminate', certificate: 'auto' }
|
||||||
);
|
},
|
||||||
|
headers: {
|
||||||
// Add rate limiting — 100 requests per minute per IP
|
response: {
|
||||||
apiRoute = addRateLimiting(apiRoute, {
|
'Access-Control-Allow-Origin': '*',
|
||||||
maxRequests: 100,
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
window: 60,
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
keyBy: 'ip'
|
'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)
|
### 🎮 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:
|
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
|
```typescript
|
||||||
import { SmartProxy, createSocketHandlerRoute, SocketHandlers } from '@push.rocks/smartproxy';
|
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Use pre-built handlers
|
const proxy = new SmartProxy({
|
||||||
const echoRoute = createSocketHandlerRoute(
|
routes: [
|
||||||
'echo.example.com',
|
// Use pre-built handlers
|
||||||
7777,
|
{
|
||||||
SocketHandlers.echo
|
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
|
socket.on('data', (data) => {
|
||||||
const customRoute = createSocketHandlerRoute(
|
const command = data.toString().trim();
|
||||||
'custom.example.com',
|
switch (command) {
|
||||||
9999,
|
case 'PING': socket.write('PONG\n'); break;
|
||||||
async (socket) => {
|
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
|
||||||
console.log(`New connection on custom protocol`);
|
case 'QUIT': socket.end('Goodbye!\n'); break;
|
||||||
socket.write('Welcome to my custom protocol!\n');
|
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:**
|
**Pre-built Socket Handlers:**
|
||||||
@@ -384,23 +418,26 @@ const dualStackRoute: IRouteConfig = {
|
|||||||
|
|
||||||
### ⚡ High-Performance NFTables Forwarding
|
### ⚡ High-Performance NFTables Forwarding
|
||||||
|
|
||||||
For ultra-low latency on Linux, use kernel-level forwarding (requires root):
|
For ultra-low latency on Linux, use kernel-level forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables) (requires root):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy, createNfTablesTerminateRoute } from '@push.rocks/smartproxy';
|
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
routes: [
|
routes: [{
|
||||||
createNfTablesTerminateRoute(
|
name: 'nftables-fast',
|
||||||
'fast.example.com',
|
match: { ports: 443, domains: 'fast.example.com' },
|
||||||
{ host: 'backend', port: 8080 },
|
action: {
|
||||||
{
|
type: 'forward',
|
||||||
ports: 443,
|
forwardingEngine: 'nftables',
|
||||||
certificate: 'auto',
|
targets: [{ host: 'backend', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
nftables: {
|
||||||
|
protocol: 'tcp',
|
||||||
preserveSourceIP: true // Backend sees real client IP
|
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:
|
Forward encrypted traffic to backends without terminating TLS — the proxy routes based on the SNI hostname alone:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartProxy, createHttpsPassthroughRoute } from '@push.rocks/smartproxy';
|
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
routes: [
|
routes: [{
|
||||||
createHttpsPassthroughRoute('secure.example.com', {
|
name: 'sni-passthrough',
|
||||||
host: 'backend-that-handles-tls',
|
match: { ports: 443, domains: 'secure.example.com' },
|
||||||
port: 8443
|
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:
|
Security options are configured directly on each route's `security` property — no separate helpers needed.
|
||||||
|
|
||||||
```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' }] });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📊 Runtime Management
|
### 📊 Runtime Management
|
||||||
|
|
||||||
@@ -694,22 +726,26 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
|
|||||||
│ │ Listener│ │ Reverse │ │ Matcher │ │ Cert Mgr │ │
|
│ │ Listener│ │ Reverse │ │ Matcher │ │ Cert Mgr │ │
|
||||||
│ │ │ │ Proxy │ │ │ │ │ │
|
│ │ │ │ Proxy │ │ │ │ │ │
|
||||||
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
|
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
|
||||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
│ │ UDP │ │ Security│ │ Metrics │ │ NFTables │ │
|
│ │ UDP │ │ Security│ │ Metrics │ │
|
||||||
│ │ QUIC │ │ Enforce │ │ Collect │ │ Mgr │ │
|
│ │ QUIC │ │ Enforce │ │ Collect │ │
|
||||||
│ │ HTTP/3 │ │ │ │ │ │ │ │
|
│ │ HTTP/3 │ │ │ │ │ │
|
||||||
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
|
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||||
└──────────────────┬──────────────────────────────────┘
|
└──────────────────┬──────────────────────────────────┘
|
||||||
│ Unix Socket Relay
|
│ Unix Socket Relay
|
||||||
┌──────────────────▼──────────────────────────────────┐
|
┌──────────────────▼──────────────────────────────────┐
|
||||||
│ TypeScript Socket & Datagram Handler Servers │
|
│ TypeScript Socket & Datagram Handler Servers │
|
||||||
│ (for JS socket handlers, datagram handlers, │
|
│ (for JS socket handlers, datagram handlers, │
|
||||||
│ and dynamic routes) │
|
│ and dynamic routes) │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ @push.rocks/smartnftables (kernel-level NFTables) │
|
||||||
|
│ (DNAT/SNAT, firewall, rate limiting via nft CLI) │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Rust Engine** handles all networking: TCP, UDP, TLS, QUIC, HTTP proxying, connection management, security, and metrics
|
- **Rust Engine** handles all networking: TCP, UDP, TLS, QUIC, HTTP proxying, connection management, security, and metrics
|
||||||
- **TypeScript** provides the npm API, configuration types, route helpers, validation, and 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
|
- **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)
|
- **Socket/Datagram Relay** — Unix domain socket servers for routes requiring TypeScript-side handling (socket handlers, datagram handlers, dynamic host/port functions)
|
||||||
|
|
||||||
@@ -854,47 +890,13 @@ interface IRouteQuic {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ Helper Functions Reference
|
## 🛠️ Exports Reference
|
||||||
|
|
||||||
All helpers are fully typed and return `IRouteConfig` or `IRouteConfig[]`:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
// HTTP/HTTPS
|
// Core
|
||||||
createHttpRoute, // Plain HTTP route
|
SmartProxy, // Main proxy class
|
||||||
createHttpsTerminateRoute, // HTTPS with TLS termination
|
SocketHandlers, // Pre-built socket handlers (echo, proxy, block, httpRedirect, httpServer, etc.)
|
||||||
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
|
|
||||||
|
|
||||||
// Route Utilities
|
// Route Utilities
|
||||||
mergeRouteConfigs, // Deep-merge two route configs
|
mergeRouteConfigs, // Deep-merge two route configs
|
||||||
@@ -906,7 +908,7 @@ import {
|
|||||||
} from '@push.rocks/smartproxy';
|
} from '@push.rocks/smartproxy';
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Tip:** For UDP datagram handler routes or QUIC/HTTP3 routes, construct `IRouteConfig` objects directly — there are no helper functions for these yet. See the [UDP Datagram Handler](#-udp-datagram-handler) and [QUIC / HTTP3 Forwarding](#-quic--http3-forwarding) examples above.
|
All routes are configured as plain `IRouteConfig` objects with `match` and `action` properties — see the examples throughout this document.
|
||||||
|
|
||||||
## 📖 API Documentation
|
## 📖 API Documentation
|
||||||
|
|
||||||
@@ -938,8 +940,8 @@ class SmartProxy extends EventEmitter {
|
|||||||
getCertificateStatus(routeName: string): Promise<any>;
|
getCertificateStatus(routeName: string): Promise<any>;
|
||||||
getEligibleDomainsForCertificates(): string[];
|
getEligibleDomainsForCertificates(): string[];
|
||||||
|
|
||||||
// NFTables
|
// NFTables (managed by @push.rocks/smartnftables)
|
||||||
getNfTablesStatus(): Promise<Record<string, any>>;
|
getNfTablesStatus(): INftStatus | null;
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
on(event: 'error', handler: (err: Error) => void): this;
|
on(event: 'error', handler: (err: Error) => void): this;
|
||||||
@@ -991,11 +993,11 @@ interface ISmartProxyOptions {
|
|||||||
sendProxyProtocol?: boolean; // Send PROXY protocol to targets
|
sendProxyProtocol?: boolean; // Send PROXY protocol to targets
|
||||||
|
|
||||||
// Timeouts
|
// Timeouts
|
||||||
connectionTimeout?: number; // Backend connection timeout (default: 30s)
|
connectionTimeout?: number; // Backend connection timeout (default: 60s)
|
||||||
initialDataTimeout?: number; // Initial data/SNI timeout (default: 120s)
|
initialDataTimeout?: number; // Initial data/SNI timeout (default: 60s)
|
||||||
socketTimeout?: number; // Socket inactivity timeout (default: 1h)
|
socketTimeout?: number; // Socket inactivity timeout (default: 60s)
|
||||||
maxConnectionLifetime?: number; // Max connection lifetime (default: 24h)
|
maxConnectionLifetime?: number; // Max connection lifetime (default: 1h)
|
||||||
inactivityTimeout?: number; // Inactivity timeout (default: 4h)
|
inactivityTimeout?: number; // Inactivity timeout (default: 75s)
|
||||||
gracefulShutdownTimeout?: number; // Shutdown grace period (default: 30s)
|
gracefulShutdownTimeout?: number; // Shutdown grace period (default: 30s)
|
||||||
|
|
||||||
// Connection limits
|
// Connection limits
|
||||||
@@ -1004,8 +1006,8 @@ interface ISmartProxyOptions {
|
|||||||
|
|
||||||
// Keep-alive
|
// Keep-alive
|
||||||
keepAliveTreatment?: 'standard' | 'extended' | 'immortal';
|
keepAliveTreatment?: 'standard' | 'extended' | 'immortal';
|
||||||
keepAliveInactivityMultiplier?: number; // (default: 6)
|
keepAliveInactivityMultiplier?: number; // (default: 4)
|
||||||
extendedKeepAliveLifetime?: number; // (default: 7 days)
|
extendedKeepAliveLifetime?: number; // (default: 1h)
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
metrics?: {
|
metrics?: {
|
||||||
@@ -1137,7 +1139,7 @@ SmartProxy searches for the Rust binary in this order:
|
|||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE 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. |
|
||||||
Generated
+1
-15
@@ -1238,7 +1238,6 @@ dependencies = [
|
|||||||
"rustproxy-config",
|
"rustproxy-config",
|
||||||
"rustproxy-http",
|
"rustproxy-http",
|
||||||
"rustproxy-metrics",
|
"rustproxy-metrics",
|
||||||
"rustproxy-nftables",
|
|
||||||
"rustproxy-passthrough",
|
"rustproxy-passthrough",
|
||||||
"rustproxy-routing",
|
"rustproxy-routing",
|
||||||
"rustproxy-security",
|
"rustproxy-security",
|
||||||
@@ -1270,6 +1269,7 @@ dependencies = [
|
|||||||
"arc-swap",
|
"arc-swap",
|
||||||
"bytes",
|
"bytes",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"futures",
|
||||||
"h3",
|
"h3",
|
||||||
"h3-quinn",
|
"h3-quinn",
|
||||||
"http-body",
|
"http-body",
|
||||||
@@ -1303,20 +1303,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustproxy-nftables"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"libc",
|
|
||||||
"rustproxy-config",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustproxy-passthrough"
|
name = "rustproxy-passthrough"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ members = [
|
|||||||
"crates/rustproxy-tls",
|
"crates/rustproxy-tls",
|
||||||
"crates/rustproxy-passthrough",
|
"crates/rustproxy-passthrough",
|
||||||
"crates/rustproxy-http",
|
"crates/rustproxy-http",
|
||||||
"crates/rustproxy-nftables",
|
|
||||||
"crates/rustproxy-metrics",
|
"crates/rustproxy-metrics",
|
||||||
"crates/rustproxy-security",
|
"crates/rustproxy-security",
|
||||||
]
|
]
|
||||||
@@ -107,6 +106,5 @@ rustproxy-routing = { path = "crates/rustproxy-routing" }
|
|||||||
rustproxy-tls = { path = "crates/rustproxy-tls" }
|
rustproxy-tls = { path = "crates/rustproxy-tls" }
|
||||||
rustproxy-passthrough = { path = "crates/rustproxy-passthrough" }
|
rustproxy-passthrough = { path = "crates/rustproxy-passthrough" }
|
||||||
rustproxy-http = { path = "crates/rustproxy-http" }
|
rustproxy-http = { path = "crates/rustproxy-http" }
|
||||||
rustproxy-nftables = { path = "crates/rustproxy-nftables" }
|
|
||||||
rustproxy-metrics = { path = "crates/rustproxy-metrics" }
|
rustproxy-metrics = { path = "crates/rustproxy-metrics" }
|
||||||
rustproxy-security = { path = "crates/rustproxy-security" }
|
rustproxy-security = { path = "crates/rustproxy-security" }
|
||||||
|
|||||||
@@ -1,345 +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,
|
|
||||||
forwarding_engine: None,
|
|
||||||
nftables: 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,
|
|
||||||
forwarding_engine: None,
|
|
||||||
nftables: 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,
|
|
||||||
forwarding_engine: None,
|
|
||||||
nftables: 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"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ pub mod proxy_options;
|
|||||||
pub mod tls_types;
|
pub mod tls_types;
|
||||||
pub mod security_types;
|
pub mod security_types;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
pub mod helpers;
|
|
||||||
|
|
||||||
// Re-export all primary types
|
// Re-export all primary types
|
||||||
pub use route_types::*;
|
pub use route_types::*;
|
||||||
@@ -16,4 +15,3 @@ pub use proxy_options::*;
|
|||||||
pub use tls_types::*;
|
pub use tls_types::*;
|
||||||
pub use security_types::*;
|
pub use security_types::*;
|
||||||
pub use validation::*;
|
pub use validation::*;
|
||||||
pub use helpers::*;
|
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ pub struct RustProxyOptions {
|
|||||||
pub defaults: Option<DefaultConfig>,
|
pub defaults: Option<DefaultConfig>,
|
||||||
|
|
||||||
// ─── Timeout Settings ────────────────────────────────────────────
|
// ─── Timeout Settings ────────────────────────────────────────────
|
||||||
|
|
||||||
/// Timeout for establishing connection to backend (ms), default: 30000
|
/// Timeout for establishing connection to backend (ms), default: 30000
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub connection_timeout: Option<u64>,
|
pub connection_timeout: Option<u64>,
|
||||||
@@ -159,7 +158,6 @@ pub struct RustProxyOptions {
|
|||||||
pub graceful_shutdown_timeout: Option<u64>,
|
pub graceful_shutdown_timeout: Option<u64>,
|
||||||
|
|
||||||
// ─── Socket Optimization ─────────────────────────────────────────
|
// ─── Socket Optimization ─────────────────────────────────────────
|
||||||
|
|
||||||
/// Disable Nagle's algorithm (default: true)
|
/// Disable Nagle's algorithm (default: true)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub no_delay: Option<bool>,
|
pub no_delay: Option<bool>,
|
||||||
@@ -177,7 +175,6 @@ pub struct RustProxyOptions {
|
|||||||
pub max_pending_data_size: Option<u64>,
|
pub max_pending_data_size: Option<u64>,
|
||||||
|
|
||||||
// ─── Enhanced Features ───────────────────────────────────────────
|
// ─── Enhanced Features ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Disable inactivity checking entirely
|
/// Disable inactivity checking entirely
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub disable_inactivity_check: Option<bool>,
|
pub disable_inactivity_check: Option<bool>,
|
||||||
@@ -199,7 +196,6 @@ pub struct RustProxyOptions {
|
|||||||
pub enable_randomized_timeouts: Option<bool>,
|
pub enable_randomized_timeouts: Option<bool>,
|
||||||
|
|
||||||
// ─── Rate Limiting ───────────────────────────────────────────────
|
// ─── Rate Limiting ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// Maximum simultaneous connections from a single IP
|
/// Maximum simultaneous connections from a single IP
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_connections_per_ip: Option<u64>,
|
pub max_connections_per_ip: Option<u64>,
|
||||||
@@ -213,7 +209,6 @@ pub struct RustProxyOptions {
|
|||||||
pub max_connections: Option<u64>,
|
pub max_connections: Option<u64>,
|
||||||
|
|
||||||
// ─── Keep-Alive Settings ─────────────────────────────────────────
|
// ─── Keep-Alive Settings ─────────────────────────────────────────
|
||||||
|
|
||||||
/// How to treat keep-alive connections
|
/// How to treat keep-alive connections
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub keep_alive_treatment: Option<KeepAliveTreatment>,
|
pub keep_alive_treatment: Option<KeepAliveTreatment>,
|
||||||
@@ -227,7 +222,6 @@ pub struct RustProxyOptions {
|
|||||||
pub extended_keep_alive_lifetime: Option<u64>,
|
pub extended_keep_alive_lifetime: Option<u64>,
|
||||||
|
|
||||||
// ─── HttpProxy Integration ───────────────────────────────────────
|
// ─── HttpProxy Integration ───────────────────────────────────────
|
||||||
|
|
||||||
/// Array of ports to forward to HttpProxy
|
/// Array of ports to forward to HttpProxy
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub use_http_proxy: Option<Vec<u16>>,
|
pub use_http_proxy: Option<Vec<u16>>,
|
||||||
@@ -237,13 +231,11 @@ pub struct RustProxyOptions {
|
|||||||
pub http_proxy_port: Option<u16>,
|
pub http_proxy_port: Option<u16>,
|
||||||
|
|
||||||
// ─── Metrics ─────────────────────────────────────────────────────
|
// ─── Metrics ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Metrics configuration
|
/// Metrics configuration
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub metrics: Option<MetricsConfig>,
|
pub metrics: Option<MetricsConfig>,
|
||||||
|
|
||||||
// ─── ACME ────────────────────────────────────────────────────────
|
// ─── ACME ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Global ACME configuration
|
/// Global ACME configuration
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub acme: Option<AcmeOptions>,
|
pub acme: Option<AcmeOptions>,
|
||||||
@@ -318,7 +310,8 @@ impl RustProxyOptions {
|
|||||||
|
|
||||||
/// Get all unique ports that routes listen on.
|
/// Get all unique ports that routes listen on.
|
||||||
pub fn all_listening_ports(&self) -> Vec<u16> {
|
pub fn all_listening_ports(&self) -> Vec<u16> {
|
||||||
let mut ports: Vec<u16> = self.routes
|
let mut ports: Vec<u16> = self
|
||||||
|
.routes
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|r| r.listening_ports())
|
.flat_map(|r| r.listening_ports())
|
||||||
.collect();
|
.collect();
|
||||||
@@ -331,12 +324,73 @@ impl RustProxyOptions {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_serde_roundtrip_minimal() {
|
fn test_serde_roundtrip_minimal() {
|
||||||
let options = RustProxyOptions {
|
let options = RustProxyOptions {
|
||||||
routes: vec![create_http_route("example.com", "localhost", 8080)],
|
routes: vec![make_route("example.com", "localhost", 8080, 80)],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&options).unwrap();
|
let json = serde_json::to_string(&options).unwrap();
|
||||||
@@ -348,8 +402,8 @@ mod tests {
|
|||||||
fn test_serde_roundtrip_full() {
|
fn test_serde_roundtrip_full() {
|
||||||
let options = RustProxyOptions {
|
let options = RustProxyOptions {
|
||||||
routes: vec![
|
routes: vec![
|
||||||
create_http_route("a.com", "backend1", 8080),
|
make_route("a.com", "backend1", 8080, 80),
|
||||||
create_https_passthrough_route("b.com", "backend2", 443),
|
make_passthrough_route("b.com", "backend2", 443),
|
||||||
],
|
],
|
||||||
connection_timeout: Some(5000),
|
connection_timeout: Some(5000),
|
||||||
socket_timeout: Some(60000),
|
socket_timeout: Some(60000),
|
||||||
@@ -374,6 +428,209 @@ mod tests {
|
|||||||
assert_eq!(parsed.connection_timeout, Some(5000));
|
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]
|
#[test]
|
||||||
fn test_default_timeouts() {
|
fn test_default_timeouts() {
|
||||||
let options = RustProxyOptions::default();
|
let options = RustProxyOptions::default();
|
||||||
@@ -402,9 +659,9 @@ mod tests {
|
|||||||
fn test_all_listening_ports() {
|
fn test_all_listening_ports() {
|
||||||
let options = RustProxyOptions {
|
let options = RustProxyOptions {
|
||||||
routes: vec![
|
routes: vec![
|
||||||
create_http_route("a.com", "backend", 8080), // port 80
|
make_route("a.com", "backend", 8080, 80), // port 80
|
||||||
create_https_passthrough_route("b.com", "backend", 443), // port 443
|
make_passthrough_route("b.com", "backend", 443), // port 443
|
||||||
create_http_route("c.com", "backend", 9090), // port 80 (duplicate)
|
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -428,9 +685,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_deserialize_example_json() {
|
fn test_deserialize_example_json() {
|
||||||
let content = std::fs::read_to_string(
|
let content = std::fs::read_to_string(concat!(
|
||||||
concat!(env!("CARGO_MANIFEST_DIR"), "/../../config/example.json")
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
).unwrap();
|
"/../../config/example.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
let options: RustProxyOptions = serde_json::from_str(&content).unwrap();
|
let options: RustProxyOptions = serde_json::from_str(&content).unwrap();
|
||||||
assert_eq!(options.routes.len(), 4);
|
assert_eq!(options.routes.len(), 4);
|
||||||
let ports = options.all_listening_ports();
|
let ports = options.all_listening_ports();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::tls_types::RouteTls;
|
|
||||||
use crate::security_types::RouteSecurity;
|
use crate::security_types::RouteSecurity;
|
||||||
|
use crate::tls_types::RouteTls;
|
||||||
|
|
||||||
// ─── Port Range ──────────────────────────────────────────────────────
|
// ─── Port Range ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -32,12 +32,13 @@ impl PortRange {
|
|||||||
pub fn to_ports(&self) -> Vec<u16> {
|
pub fn to_ports(&self) -> Vec<u16> {
|
||||||
match self {
|
match self {
|
||||||
PortRange::Single(p) => vec![*p],
|
PortRange::Single(p) => vec![*p],
|
||||||
PortRange::List(items) => {
|
PortRange::List(items) => items
|
||||||
items.iter().flat_map(|item| match item {
|
.iter()
|
||||||
|
.flat_map(|item| match item {
|
||||||
PortRangeItem::Port(p) => vec![*p],
|
PortRangeItem::Port(p) => vec![*p],
|
||||||
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
|
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
|
||||||
}).collect()
|
})
|
||||||
}
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,16 +61,6 @@ pub enum RouteActionType {
|
|||||||
SocketHandler,
|
SocketHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Forwarding Engine ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Forwarding engine specification.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ForwardingEngine {
|
|
||||||
Node,
|
|
||||||
Nftables,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Route Match ─────────────────────────────────────────────────────
|
// ─── Route Match ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Domain specification: single string or array.
|
/// Domain specification: single string or array.
|
||||||
@@ -89,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.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum HeaderMatchValue {
|
pub enum HeaderMatchValue {
|
||||||
@@ -341,38 +358,6 @@ pub struct RouteAdvanced {
|
|||||||
pub url_rewrite: Option<RouteUrlRewrite>,
|
pub url_rewrite: Option<RouteUrlRewrite>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── NFTables Options ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// NFTables protocol type.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum NfTablesProtocol {
|
|
||||||
Tcp,
|
|
||||||
Udp,
|
|
||||||
All,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NFTables-specific configuration options.
|
|
||||||
/// Matches TypeScript: `INfTablesOptions`
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct NfTablesOptions {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub preserve_source_ip: Option<bool>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub protocol: Option<NfTablesProtocol>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_rate: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub priority: Option<i32>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub table_name: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub use_ip_sets: Option<bool>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub use_advanced_nat: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Backend Protocol ────────────────────────────────────────────────
|
// ─── Backend Protocol ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Backend protocol.
|
/// Backend protocol.
|
||||||
@@ -541,14 +526,6 @@ pub struct RouteAction {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub options: Option<ActionOptions>,
|
pub options: Option<ActionOptions>,
|
||||||
|
|
||||||
/// Forwarding engine specification
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub forwarding_engine: Option<ForwardingEngine>,
|
|
||||||
|
|
||||||
/// NFTables-specific options
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub nftables: Option<NfTablesOptions>,
|
|
||||||
|
|
||||||
/// PROXY protocol support (default for all targets)
|
/// PROXY protocol support (default for all targets)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub send_proxy_protocol: Option<bool>,
|
pub send_proxy_protocol: Option<bool>,
|
||||||
|
|||||||
@@ -103,14 +103,30 @@ pub struct JwtAuthConfig {
|
|||||||
pub exclude_paths: Option<Vec<String>>,
|
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.
|
/// Security options for routes.
|
||||||
/// Matches TypeScript: `IRouteSecurity`
|
/// Matches TypeScript: `IRouteSecurity`
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RouteSecurity {
|
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")]
|
#[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
|
/// IP addresses that are blocked from connecting
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ip_block_list: Option<Vec<String>>,
|
pub ip_block_list: Option<Vec<String>>,
|
||||||
|
|||||||
@@ -104,7 +104,49 @@ mod tests {
|
|||||||
use crate::route_types::*;
|
use crate::route_types::*;
|
||||||
|
|
||||||
fn make_valid_route() -> RouteConfig {
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ socket2 = { workspace = true }
|
|||||||
quinn = { workspace = true }
|
quinn = { workspace = true }
|
||||||
h3 = { workspace = true }
|
h3 = { workspace = true }
|
||||||
h3-quinn = { workspace = true }
|
h3-quinn = { workspace = true }
|
||||||
|
futures = { version = "0.3", default-features = false, features = ["std"] }
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ struct PooledH2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A pooled QUIC/HTTP/3 connection (multiplexed like H2).
|
/// A pooled QUIC/HTTP/3 connection (multiplexed like H2).
|
||||||
|
/// Stores the h3 `SendRequest` handle so pool hits skip the h3 SETTINGS handshake.
|
||||||
pub struct PooledH3 {
|
pub struct PooledH3 {
|
||||||
|
/// Multiplexed h3 request handle — clone to open a new stream.
|
||||||
|
pub send_request: h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>,
|
||||||
|
/// Raw QUIC connection — kept for liveness probing (close_reason) only.
|
||||||
pub connection: quinn::Connection,
|
pub connection: quinn::Connection,
|
||||||
pub created_at: Instant,
|
pub created_at: Instant,
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
@@ -197,7 +201,10 @@ impl ConnectionPool {
|
|||||||
|
|
||||||
/// Try to get a pooled QUIC connection for the given key.
|
/// Try to get a pooled QUIC connection for the given key.
|
||||||
/// QUIC connections are multiplexed — the connection is shared, not removed.
|
/// QUIC connections are multiplexed — the connection is shared, not removed.
|
||||||
pub fn checkout_h3(&self, key: &PoolKey) -> Option<(quinn::Connection, Duration)> {
|
pub fn checkout_h3(
|
||||||
|
&self,
|
||||||
|
key: &PoolKey,
|
||||||
|
) -> Option<(h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>, quinn::Connection, Duration)> {
|
||||||
let entry = self.h3_pool.get(key)?;
|
let entry = self.h3_pool.get(key)?;
|
||||||
let pooled = entry.value();
|
let pooled = entry.value();
|
||||||
let age = pooled.created_at.elapsed();
|
let age = pooled.created_at.elapsed();
|
||||||
@@ -215,13 +222,20 @@ impl ConnectionPool {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some((pooled.connection.clone(), age))
|
Some((pooled.send_request.clone(), pooled.connection.clone(), age))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a QUIC connection in the pool. Returns the generation ID.
|
/// Register a QUIC connection and its h3 SendRequest handle in the pool.
|
||||||
pub fn register_h3(&self, key: PoolKey, connection: quinn::Connection) -> u64 {
|
/// Returns the generation ID.
|
||||||
|
pub fn register_h3(
|
||||||
|
&self,
|
||||||
|
key: PoolKey,
|
||||||
|
connection: quinn::Connection,
|
||||||
|
send_request: h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>,
|
||||||
|
) -> u64 {
|
||||||
let gen = self.h2_generation.fetch_add(1, Ordering::Relaxed);
|
let gen = self.h2_generation.fetch_add(1, Ordering::Relaxed);
|
||||||
self.h3_pool.insert(key, PooledH3 {
|
self.h3_pool.insert(key, PooledH3 {
|
||||||
|
send_request,
|
||||||
connection,
|
connection,
|
||||||
created_at: Instant::now(),
|
created_at: Instant::now(),
|
||||||
generation: gen,
|
generation: gen,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use tracing::{debug, warn};
|
|||||||
use rustproxy_config::RouteConfig;
|
use rustproxy_config::RouteConfig;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::proxy_service::{ConnActivity, HttpProxyService};
|
use crate::proxy_service::{ConnActivity, HttpProxyService, ProtocolGuard};
|
||||||
|
|
||||||
/// HTTP/3 proxy service.
|
/// HTTP/3 proxy service.
|
||||||
///
|
///
|
||||||
@@ -48,6 +48,9 @@ impl H3ProxyService {
|
|||||||
let remote_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
|
let remote_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
|
||||||
debug!("HTTP/3 connection from {} on port {}", remote_addr, port);
|
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> =
|
let mut h3_conn: h3::server::Connection<h3_quinn::Connection, Bytes> =
|
||||||
h3::server::builder()
|
h3::server::builder()
|
||||||
.send_grease(false)
|
.send_grease(false)
|
||||||
@@ -116,7 +119,7 @@ async fn handle_h3_request(
|
|||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// Stream request body from H3 client via an mpsc channel.
|
// Stream request body from H3 client via an mpsc channel.
|
||||||
let (body_tx, body_rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
let (body_tx, body_rx) = tokio::sync::mpsc::channel::<Bytes>(32);
|
||||||
|
|
||||||
// Spawn the H3 body reader task with cancellation
|
// Spawn the H3 body reader task with cancellation
|
||||||
let body_cancel = cancel.clone();
|
let body_cancel = cancel.clone();
|
||||||
@@ -132,8 +135,7 @@ async fn handle_h3_request(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut chunk = chunk;
|
let mut chunk = chunk;
|
||||||
let data = Bytes::copy_from_slice(chunk.chunk());
|
let data = chunk.copy_to_bytes(chunk.remaining());
|
||||||
chunk.advance(chunk.remaining());
|
|
||||||
if body_tx.send(data).await.is_err() {
|
if body_tx.send(data).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -179,8 +181,8 @@ async fn handle_h3_request(
|
|||||||
while let Some(frame) = resp_body.frame().await {
|
while let Some(frame) = resp_body.frame().await {
|
||||||
match frame {
|
match frame {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
if let Some(data) = frame.data_ref() {
|
if let Ok(data) = frame.into_data() {
|
||||||
stream.send_data(Bytes::copy_from_slice(data)).await
|
stream.send_data(data).await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to send H3 data: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to send H3 data: {}", e))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,99 @@
|
|||||||
//! Bounded, TTL-based protocol detection cache for backend protocol auto-detection.
|
//! Bounded, sliding-TTL protocol detection cache with periodic re-probing and failure suppression.
|
||||||
//!
|
//!
|
||||||
//! Caches the detected protocol (H1, H2, or H3) per backend endpoint and requested
|
//! Caches the detected protocol (H1, H2, or H3) per backend endpoint and requested
|
||||||
//! domain (host:port + requested_host). This prevents cache oscillation when multiple
|
//! domain (host:port + requested_host). This prevents cache oscillation when multiple
|
||||||
//! frontend domains share the same backend but differ in protocol support.
|
//! frontend domains share the same backend but differ in protocol support.
|
||||||
//!
|
//!
|
||||||
//! H3 detection uses the browser model: Alt-Svc headers from H1/H2 responses are
|
//! ## Sliding TTL
|
||||||
//! parsed and cached, including the advertised H3 port (which may differ from TCP).
|
//!
|
||||||
|
//! Each cache hit refreshes the entry's expiry timer (`last_accessed_at`). Entries
|
||||||
|
//! remain valid for up to 1 day of continuous use. Every 5 minutes, the next request
|
||||||
|
//! triggers an inline ALPN re-probe to verify the cached protocol is still correct.
|
||||||
|
//!
|
||||||
|
//! ## Upgrade signals
|
||||||
|
//!
|
||||||
|
//! - ALPN (TLS handshake) → detects H2 vs H1
|
||||||
|
//! - Alt-Svc (response header) → advertises H3
|
||||||
|
//!
|
||||||
|
//! ## Protocol transitions
|
||||||
|
//!
|
||||||
|
//! All protocol changes are logged at `info!()` level with the reason:
|
||||||
|
//! "Protocol transition: H1 → H2 because periodic ALPN re-probe"
|
||||||
|
//!
|
||||||
|
//! ## Failure suppression
|
||||||
|
//!
|
||||||
|
//! When a protocol fails, `record_failure()` prevents upgrade signals from
|
||||||
|
//! re-introducing it until an escalating cooldown expires (5s → 10s → ... → 300s).
|
||||||
|
//! Within-request escalation is allowed via `can_retry()` after a 5s minimum gap.
|
||||||
|
//!
|
||||||
|
//! ## Total failure eviction
|
||||||
|
//!
|
||||||
|
//! When all protocols (H3, H2, H1) fail for a backend, the cache entry is evicted
|
||||||
|
//! entirely via `evict()`, forcing a fresh probe on the next request.
|
||||||
|
//!
|
||||||
|
//! Cascading: when a lower protocol also fails, higher protocol cooldowns are
|
||||||
|
//! reduced to 5s remaining (not instant clear), preventing tight retry loops.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use tracing::debug;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
/// TTL for cached protocol detection results.
|
/// Sliding TTL for cached protocol detection results.
|
||||||
/// After this duration, the next request will re-probe the backend.
|
/// Entries that haven't been accessed for this duration are evicted.
|
||||||
const PROTOCOL_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
/// Each `get()` call refreshes the timer (sliding window).
|
||||||
|
const PROTOCOL_CACHE_TTL: Duration = Duration::from_secs(86400); // 1 day
|
||||||
|
|
||||||
|
/// Interval between inline ALPN re-probes for H1/H2 entries.
|
||||||
|
/// When a cached entry's `last_probed_at` exceeds this, the next request
|
||||||
|
/// triggers an ALPN re-probe to verify the backend still speaks the same protocol.
|
||||||
|
const PROTOCOL_REPROBE_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes
|
||||||
|
|
||||||
/// Maximum number of entries in the protocol cache.
|
/// Maximum number of entries in the protocol cache.
|
||||||
/// Prevents unbounded growth when backends come and go.
|
|
||||||
const PROTOCOL_CACHE_MAX_ENTRIES: usize = 4096;
|
const PROTOCOL_CACHE_MAX_ENTRIES: usize = 4096;
|
||||||
|
|
||||||
/// Background cleanup interval for the protocol cache.
|
/// Background cleanup interval.
|
||||||
const PROTOCOL_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
const PROTOCOL_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Minimum cooldown between retry attempts of a failed protocol.
|
||||||
|
const PROTOCOL_FAILURE_COOLDOWN: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Maximum cooldown (escalation ceiling).
|
||||||
|
const PROTOCOL_FAILURE_MAX_COOLDOWN: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Consecutive failure count at which cooldown reaches maximum.
|
||||||
|
/// 5s × 2^5 = 160s, 5s × 2^6 = 320s → capped at 300s.
|
||||||
|
const PROTOCOL_FAILURE_ESCALATION_CAP: u32 = 6;
|
||||||
|
|
||||||
/// Detected backend protocol.
|
/// Detected backend protocol.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum DetectedProtocol {
|
pub enum DetectedProtocol {
|
||||||
H1,
|
H1,
|
||||||
H2,
|
H2,
|
||||||
H3,
|
H3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DetectedProtocol {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
DetectedProtocol::H1 => write!(f, "H1"),
|
||||||
|
DetectedProtocol::H2 => write!(f, "H2"),
|
||||||
|
DetectedProtocol::H3 => write!(f, "H3"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a protocol cache lookup.
|
/// Result of a protocol cache lookup.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct CachedProtocol {
|
pub struct CachedProtocol {
|
||||||
pub protocol: DetectedProtocol,
|
pub protocol: DetectedProtocol,
|
||||||
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
|
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
|
||||||
pub h3_port: Option<u16>,
|
pub h3_port: Option<u16>,
|
||||||
|
/// True if the entry's `last_probed_at` exceeds `PROTOCOL_REPROBE_INTERVAL`.
|
||||||
|
/// Caller should perform an inline ALPN re-probe and call `update_probe_result()`.
|
||||||
|
/// Always `false` for H3 entries (H3 is discovered via Alt-Svc, not ALPN).
|
||||||
|
pub needs_reprobe: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key for the protocol cache: (host, port, requested_host).
|
/// Key for the protocol cache: (host, port, requested_host).
|
||||||
@@ -50,24 +106,111 @@ pub struct ProtocolCacheKey {
|
|||||||
pub requested_host: Option<String>,
|
pub requested_host: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cached protocol detection result with a timestamp.
|
/// A cached protocol detection result with timestamps.
|
||||||
struct CachedEntry {
|
struct CachedEntry {
|
||||||
protocol: DetectedProtocol,
|
protocol: DetectedProtocol,
|
||||||
|
/// When this protocol was first detected (or last changed).
|
||||||
detected_at: Instant,
|
detected_at: Instant,
|
||||||
|
/// Last time any request used this entry (sliding-window TTL).
|
||||||
|
last_accessed_at: Instant,
|
||||||
|
/// Last time an ALPN re-probe was performed for this entry.
|
||||||
|
last_probed_at: Instant,
|
||||||
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
|
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
|
||||||
h3_port: Option<u16>,
|
h3_port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded, TTL-based protocol detection cache.
|
/// Failure record for a single protocol level.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct FailureRecord {
|
||||||
|
/// When the failure was last recorded.
|
||||||
|
failed_at: Instant,
|
||||||
|
/// Current cooldown duration. Escalates on consecutive failures.
|
||||||
|
cooldown: Duration,
|
||||||
|
/// Number of consecutive failures (for escalation).
|
||||||
|
consecutive_failures: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-key failure state. Tracks failures at each upgradeable protocol level.
|
||||||
|
/// H1 is never tracked (it's the protocol floor — nothing to fall back to).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct FailureState {
|
||||||
|
h2: Option<FailureRecord>,
|
||||||
|
h3: Option<FailureRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailureState {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.h2.is_none() && self.h3.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_expired(&self) -> bool {
|
||||||
|
let h2_expired = self.h2.as_ref()
|
||||||
|
.map(|r| r.failed_at.elapsed() >= r.cooldown)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let h3_expired = self.h3.as_ref()
|
||||||
|
.map(|r| r.failed_at.elapsed() >= r.cooldown)
|
||||||
|
.unwrap_or(true);
|
||||||
|
h2_expired && h3_expired
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, protocol: DetectedProtocol) -> Option<&FailureRecord> {
|
||||||
|
match protocol {
|
||||||
|
DetectedProtocol::H2 => self.h2.as_ref(),
|
||||||
|
DetectedProtocol::H3 => self.h3.as_ref(),
|
||||||
|
DetectedProtocol::H1 => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_mut(&mut self, protocol: DetectedProtocol) -> &mut Option<FailureRecord> {
|
||||||
|
match protocol {
|
||||||
|
DetectedProtocol::H2 => &mut self.h2,
|
||||||
|
DetectedProtocol::H3 => &mut self.h3,
|
||||||
|
DetectedProtocol::H1 => unreachable!("H1 failures are never recorded"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of a single protocol cache entry, suitable for metrics/UI display.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProtocolCacheEntry {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub domain: Option<String>,
|
||||||
|
pub protocol: String,
|
||||||
|
pub h3_port: Option<u16>,
|
||||||
|
pub age_secs: u64,
|
||||||
|
pub last_accessed_secs: u64,
|
||||||
|
pub last_probed_secs: u64,
|
||||||
|
pub h2_suppressed: bool,
|
||||||
|
pub h3_suppressed: bool,
|
||||||
|
pub h2_cooldown_remaining_secs: Option<u64>,
|
||||||
|
pub h3_cooldown_remaining_secs: Option<u64>,
|
||||||
|
pub h2_consecutive_failures: Option<u32>,
|
||||||
|
pub h3_consecutive_failures: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exponential backoff: PROTOCOL_FAILURE_COOLDOWN × 2^(n-1), capped at MAX.
|
||||||
|
fn escalate_cooldown(consecutive: u32) -> Duration {
|
||||||
|
let base = PROTOCOL_FAILURE_COOLDOWN.as_secs();
|
||||||
|
let exp = consecutive.saturating_sub(1).min(63) as u64;
|
||||||
|
let secs = base.saturating_mul(1u64.checked_shl(exp as u32).unwrap_or(u64::MAX));
|
||||||
|
Duration::from_secs(secs.min(PROTOCOL_FAILURE_MAX_COOLDOWN.as_secs()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded, sliding-TTL protocol detection cache with failure suppression.
|
||||||
///
|
///
|
||||||
/// Memory safety guarantees:
|
/// Memory safety guarantees:
|
||||||
/// - Hard cap at `PROTOCOL_CACHE_MAX_ENTRIES` — cannot grow unboundedly.
|
/// - Hard cap at `PROTOCOL_CACHE_MAX_ENTRIES` — cannot grow unboundedly.
|
||||||
/// - TTL expiry — stale entries naturally age out on lookup.
|
/// - Sliding TTL expiry — entries age out after 1 day without access.
|
||||||
/// - Background cleanup task — proactively removes expired entries every 60s.
|
/// - Background cleanup task — proactively removes expired entries every 60s.
|
||||||
/// - `clear()` — called on route updates to discard stale detections.
|
/// - `clear()` — called on route updates to discard stale detections.
|
||||||
/// - `Drop` — aborts the background task to prevent dangling tokio tasks.
|
/// - `Drop` — aborts the background task to prevent dangling tokio tasks.
|
||||||
pub struct ProtocolCache {
|
pub struct ProtocolCache {
|
||||||
cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>>,
|
cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>>,
|
||||||
|
/// Generic protocol failure suppression map. Tracks per-protocol failure
|
||||||
|
/// records (H2, H3) for each cache key. Used to prevent upgrade signals
|
||||||
|
/// (ALPN, Alt-Svc) from re-introducing failed protocols.
|
||||||
|
failures: Arc<DashMap<ProtocolCacheKey, FailureState>>,
|
||||||
cleanup_handle: Option<tokio::task::JoinHandle<()>>,
|
cleanup_handle: Option<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,26 +218,40 @@ impl ProtocolCache {
|
|||||||
/// Create a new protocol cache and start the background cleanup task.
|
/// Create a new protocol cache and start the background cleanup task.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>> = Arc::new(DashMap::new());
|
let cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>> = Arc::new(DashMap::new());
|
||||||
|
let failures: Arc<DashMap<ProtocolCacheKey, FailureState>> = Arc::new(DashMap::new());
|
||||||
let cache_clone = Arc::clone(&cache);
|
let cache_clone = Arc::clone(&cache);
|
||||||
|
let failures_clone = Arc::clone(&failures);
|
||||||
let cleanup_handle = tokio::spawn(async move {
|
let cleanup_handle = tokio::spawn(async move {
|
||||||
Self::cleanup_loop(cache_clone).await;
|
Self::cleanup_loop(cache_clone, failures_clone).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cache,
|
cache,
|
||||||
|
failures,
|
||||||
cleanup_handle: Some(cleanup_handle),
|
cleanup_handle: Some(cleanup_handle),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up the cached protocol for a backend endpoint.
|
/// Look up the cached protocol for a backend endpoint.
|
||||||
|
///
|
||||||
/// Returns `None` if not cached or expired (caller should probe via ALPN).
|
/// Returns `None` if not cached or expired (caller should probe via ALPN).
|
||||||
|
/// On hit, refreshes `last_accessed_at` (sliding TTL) and sets `needs_reprobe`
|
||||||
|
/// if the entry hasn't been probed in over 5 minutes (H1/H2 only).
|
||||||
pub fn get(&self, key: &ProtocolCacheKey) -> Option<CachedProtocol> {
|
pub fn get(&self, key: &ProtocolCacheKey) -> Option<CachedProtocol> {
|
||||||
let entry = self.cache.get(key)?;
|
let mut entry = self.cache.get_mut(key)?;
|
||||||
if entry.detected_at.elapsed() < PROTOCOL_CACHE_TTL {
|
if entry.last_accessed_at.elapsed() < PROTOCOL_CACHE_TTL {
|
||||||
debug!("Protocol cache hit: {:?} for {}:{} (requested: {:?})", entry.protocol, key.host, key.port, key.requested_host);
|
// Refresh sliding TTL
|
||||||
|
entry.last_accessed_at = Instant::now();
|
||||||
|
|
||||||
|
// H3 is the ceiling — can't ALPN-probe for H3 (discovered via Alt-Svc).
|
||||||
|
// Only H1/H2 entries trigger periodic re-probing.
|
||||||
|
let needs_reprobe = entry.protocol != DetectedProtocol::H3
|
||||||
|
&& entry.last_probed_at.elapsed() >= PROTOCOL_REPROBE_INTERVAL;
|
||||||
|
|
||||||
Some(CachedProtocol {
|
Some(CachedProtocol {
|
||||||
protocol: entry.protocol,
|
protocol: entry.protocol,
|
||||||
h3_port: entry.h3_port,
|
h3_port: entry.h3_port,
|
||||||
|
needs_reprobe,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Expired — remove and return None to trigger re-probe
|
// Expired — remove and return None to trigger re-probe
|
||||||
@@ -105,47 +262,328 @@ impl ProtocolCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a detected protocol into the cache.
|
/// Insert a detected protocol into the cache.
|
||||||
/// If the cache is at capacity, evict the oldest entry first.
|
/// Returns `false` if suppressed due to active failure suppression.
|
||||||
pub fn insert(&self, key: ProtocolCacheKey, protocol: DetectedProtocol) {
|
///
|
||||||
self.insert_with_h3_port(key, protocol, None);
|
/// **Key semantic**: only suppresses if the protocol being inserted matches
|
||||||
|
/// a suppressed protocol. H1 inserts are NEVER suppressed — downgrades
|
||||||
|
/// always succeed.
|
||||||
|
pub fn insert(&self, key: ProtocolCacheKey, protocol: DetectedProtocol, reason: &str) -> bool {
|
||||||
|
if self.is_suppressed(&key, protocol) {
|
||||||
|
debug!(
|
||||||
|
host = %key.host, port = %key.port, domain = ?key.requested_host,
|
||||||
|
protocol = ?protocol,
|
||||||
|
"Protocol cache insert suppressed — recent failure"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.insert_internal(key, protocol, None, reason);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert an H3 detection result with the Alt-Svc advertised port.
|
/// Insert an H3 detection result with the Alt-Svc advertised port.
|
||||||
pub fn insert_h3(&self, key: ProtocolCacheKey, h3_port: u16) {
|
/// Returns `false` if H3 is suppressed.
|
||||||
self.insert_with_h3_port(key, DetectedProtocol::H3, Some(h3_port));
|
pub fn insert_h3(&self, key: ProtocolCacheKey, h3_port: u16, reason: &str) -> bool {
|
||||||
|
if self.is_suppressed(&key, DetectedProtocol::H3) {
|
||||||
|
debug!(
|
||||||
|
host = %key.host, port = %key.port, domain = ?key.requested_host,
|
||||||
|
"H3 upgrade suppressed — recent failure"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.insert_internal(key, DetectedProtocol::H3, Some(h3_port), reason);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a protocol detection result with an optional H3 port.
|
/// Update the cache after an inline ALPN re-probe completes.
|
||||||
fn insert_with_h3_port(&self, key: ProtocolCacheKey, protocol: DetectedProtocol, h3_port: Option<u16>) {
|
///
|
||||||
if self.cache.len() >= PROTOCOL_CACHE_MAX_ENTRIES && !self.cache.contains_key(&key) {
|
/// Always updates `last_probed_at`. If the protocol changed, logs the transition
|
||||||
// Evict the oldest entry to stay within bounds
|
/// and updates the entry. Returns `Some(new_protocol)` if changed, `None` if unchanged.
|
||||||
let oldest = self.cache.iter()
|
pub fn update_probe_result(
|
||||||
.min_by_key(|entry| entry.value().detected_at)
|
&self,
|
||||||
.map(|entry| entry.key().clone());
|
key: &ProtocolCacheKey,
|
||||||
if let Some(oldest_key) = oldest {
|
probed_protocol: DetectedProtocol,
|
||||||
self.cache.remove(&oldest_key);
|
reason: &str,
|
||||||
|
) -> Option<DetectedProtocol> {
|
||||||
|
if let Some(mut entry) = self.cache.get_mut(key) {
|
||||||
|
let old_protocol = entry.protocol;
|
||||||
|
entry.last_probed_at = Instant::now();
|
||||||
|
entry.last_accessed_at = Instant::now();
|
||||||
|
|
||||||
|
if old_protocol != probed_protocol {
|
||||||
|
info!(
|
||||||
|
host = %key.host, port = %key.port, domain = ?key.requested_host,
|
||||||
|
old = %old_protocol, new = %probed_protocol, reason = %reason,
|
||||||
|
"Protocol transition"
|
||||||
|
);
|
||||||
|
entry.protocol = probed_protocol;
|
||||||
|
entry.detected_at = Instant::now();
|
||||||
|
// Clear h3_port if downgrading from H3
|
||||||
|
if old_protocol == DetectedProtocol::H3 && probed_protocol != DetectedProtocol::H3 {
|
||||||
|
entry.h3_port = None;
|
||||||
|
}
|
||||||
|
return Some(probed_protocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
host = %key.host, port = %key.port, domain = ?key.requested_host,
|
||||||
|
protocol = %old_protocol, reason = %reason,
|
||||||
|
"Re-probe confirmed — no protocol change"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// Entry was evicted between the get() and the probe completing.
|
||||||
|
// Insert as a fresh entry.
|
||||||
|
self.insert_internal(key.clone(), probed_protocol, None, reason);
|
||||||
|
Some(probed_protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a protocol failure. Future `insert()` calls for this protocol
|
||||||
|
/// will be suppressed until the escalating cooldown expires.
|
||||||
|
///
|
||||||
|
/// Cooldown escalation: 5s → 10s → 20s → 40s → 80s → 160s → 300s.
|
||||||
|
/// Consecutive counter resets if the previous failure is older than 2× its cooldown.
|
||||||
|
///
|
||||||
|
/// Cascading: when H2 fails, H3 cooldown is reduced to 5s remaining.
|
||||||
|
/// H1 failures are ignored (H1 is the protocol floor).
|
||||||
|
pub fn record_failure(&self, key: ProtocolCacheKey, protocol: DetectedProtocol) {
|
||||||
|
if protocol == DetectedProtocol::H1 {
|
||||||
|
return; // H1 is the floor — nothing to suppress
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entry = self.failures.entry(key.clone()).or_default();
|
||||||
|
|
||||||
|
let record = entry.get_mut(protocol);
|
||||||
|
let (consecutive, new_cooldown) = match record {
|
||||||
|
Some(existing) if existing.failed_at.elapsed() < existing.cooldown.saturating_mul(2) => {
|
||||||
|
// Still within the "recent" window — escalate
|
||||||
|
let c = existing.consecutive_failures.saturating_add(1)
|
||||||
|
.min(PROTOCOL_FAILURE_ESCALATION_CAP);
|
||||||
|
(c, escalate_cooldown(c))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// First failure or old failure that expired long ago — reset
|
||||||
|
(1, PROTOCOL_FAILURE_COOLDOWN)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*record = Some(FailureRecord {
|
||||||
|
failed_at: Instant::now(),
|
||||||
|
cooldown: new_cooldown,
|
||||||
|
consecutive_failures: consecutive,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cascading: when H2 fails, reduce H3 cooldown to 5s remaining
|
||||||
|
if protocol == DetectedProtocol::H2 {
|
||||||
|
Self::reduce_cooldown_to(entry.h3.as_mut(), PROTOCOL_FAILURE_COOLDOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
host = %key.host, port = %key.port, domain = ?key.requested_host,
|
||||||
|
protocol = ?protocol,
|
||||||
|
consecutive = consecutive,
|
||||||
|
cooldown_secs = new_cooldown.as_secs(),
|
||||||
|
"Protocol failure recorded — suppressing for {:?}", new_cooldown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a protocol is currently suppressed for the given key.
|
||||||
|
/// Returns `true` if the protocol failed within its cooldown period.
|
||||||
|
/// H1 is never suppressed.
|
||||||
|
pub fn is_suppressed(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) -> bool {
|
||||||
|
if protocol == DetectedProtocol::H1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.failures.get(key)
|
||||||
|
.and_then(|entry| entry.get(protocol).map(|r| r.failed_at.elapsed() < r.cooldown))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a protocol can be retried (for within-request escalation).
|
||||||
|
/// Returns `true` if there's no failure record OR if ≥5s have passed since
|
||||||
|
/// the last attempt. More permissive than `is_suppressed`.
|
||||||
|
pub fn can_retry(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) -> bool {
|
||||||
|
if protocol == DetectedProtocol::H1 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match self.failures.get(key) {
|
||||||
|
Some(entry) => match entry.get(protocol) {
|
||||||
|
Some(r) => r.failed_at.elapsed() >= PROTOCOL_FAILURE_COOLDOWN,
|
||||||
|
None => true, // no failure record
|
||||||
|
},
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a retry attempt WITHOUT escalating the cooldown.
|
||||||
|
/// Resets the `failed_at` timestamp to prevent rapid retries (5s gate).
|
||||||
|
/// Called before an escalation attempt. If the attempt fails,
|
||||||
|
/// `record_failure` should be called afterward with proper escalation.
|
||||||
|
pub fn record_retry_attempt(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) {
|
||||||
|
if protocol == DetectedProtocol::H1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(mut entry) = self.failures.get_mut(key) {
|
||||||
|
if let Some(ref mut r) = entry.get_mut(protocol) {
|
||||||
|
r.failed_at = Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.cache.insert(key, CachedEntry {
|
}
|
||||||
protocol,
|
|
||||||
detected_at: Instant::now(),
|
/// Clear the failure record for a protocol (it recovered).
|
||||||
h3_port,
|
/// Called when an escalation retry succeeds.
|
||||||
});
|
pub fn clear_failure(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) {
|
||||||
|
if protocol == DetectedProtocol::H1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(mut entry) = self.failures.get_mut(key) {
|
||||||
|
*entry.get_mut(protocol) = None;
|
||||||
|
if entry.is_empty() {
|
||||||
|
drop(entry);
|
||||||
|
self.failures.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict a cache entry entirely. Called when all protocol probes (H3, H2, H1)
|
||||||
|
/// have failed for a backend.
|
||||||
|
pub fn evict(&self, key: &ProtocolCacheKey) {
|
||||||
|
self.cache.remove(key);
|
||||||
|
self.failures.remove(key);
|
||||||
|
info!(
|
||||||
|
host = %key.host, port = %key.port, domain = ?key.requested_host,
|
||||||
|
"Cache entry evicted — all protocols failed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all entries. Called on route updates to discard stale detections.
|
/// Clear all entries. Called on route updates to discard stale detections.
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
self.cache.clear();
|
self.cache.clear();
|
||||||
|
self.failures.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background cleanup loop — removes expired entries every `PROTOCOL_CACHE_CLEANUP_INTERVAL`.
|
/// Snapshot all non-expired cache entries for metrics/UI display.
|
||||||
async fn cleanup_loop(cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>>) {
|
pub fn snapshot(&self) -> Vec<ProtocolCacheEntry> {
|
||||||
|
self.cache.iter()
|
||||||
|
.filter(|entry| entry.value().last_accessed_at.elapsed() < PROTOCOL_CACHE_TTL)
|
||||||
|
.map(|entry| {
|
||||||
|
let key = entry.key();
|
||||||
|
let val = entry.value();
|
||||||
|
let failure_info = self.failures.get(key);
|
||||||
|
|
||||||
|
let (h2_sup, h2_cd, h2_cons) = Self::suppression_info(
|
||||||
|
failure_info.as_deref().and_then(|f| f.h2.as_ref()),
|
||||||
|
);
|
||||||
|
let (h3_sup, h3_cd, h3_cons) = Self::suppression_info(
|
||||||
|
failure_info.as_deref().and_then(|f| f.h3.as_ref()),
|
||||||
|
);
|
||||||
|
|
||||||
|
ProtocolCacheEntry {
|
||||||
|
host: key.host.clone(),
|
||||||
|
port: key.port,
|
||||||
|
domain: key.requested_host.clone(),
|
||||||
|
protocol: match val.protocol {
|
||||||
|
DetectedProtocol::H1 => "h1".to_string(),
|
||||||
|
DetectedProtocol::H2 => "h2".to_string(),
|
||||||
|
DetectedProtocol::H3 => "h3".to_string(),
|
||||||
|
},
|
||||||
|
h3_port: val.h3_port,
|
||||||
|
age_secs: val.detected_at.elapsed().as_secs(),
|
||||||
|
last_accessed_secs: val.last_accessed_at.elapsed().as_secs(),
|
||||||
|
last_probed_secs: val.last_probed_at.elapsed().as_secs(),
|
||||||
|
h2_suppressed: h2_sup,
|
||||||
|
h3_suppressed: h3_sup,
|
||||||
|
h2_cooldown_remaining_secs: h2_cd,
|
||||||
|
h3_cooldown_remaining_secs: h3_cd,
|
||||||
|
h2_consecutive_failures: h2_cons,
|
||||||
|
h3_consecutive_failures: h3_cons,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
/// Insert a protocol detection result with an optional H3 port.
|
||||||
|
/// Logs protocol transitions when overwriting an existing entry.
|
||||||
|
/// No suppression check — callers must check before calling.
|
||||||
|
fn insert_internal(&self, key: ProtocolCacheKey, protocol: DetectedProtocol, h3_port: Option<u16>, reason: &str) {
|
||||||
|
// Check for existing entry to log protocol transitions
|
||||||
|
if let Some(existing) = self.cache.get(&key) {
|
||||||
|
if existing.protocol != protocol {
|
||||||
|
info!(
|
||||||
|
host = %key.host, port = %key.port, domain = ?key.requested_host,
|
||||||
|
old = %existing.protocol, new = %protocol, reason = %reason,
|
||||||
|
"Protocol transition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
drop(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict oldest entry if at capacity
|
||||||
|
if self.cache.len() >= PROTOCOL_CACHE_MAX_ENTRIES && !self.cache.contains_key(&key) {
|
||||||
|
let oldest = self.cache.iter()
|
||||||
|
.min_by_key(|entry| entry.value().last_accessed_at)
|
||||||
|
.map(|entry| entry.key().clone());
|
||||||
|
if let Some(oldest_key) = oldest {
|
||||||
|
self.cache.remove(&oldest_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
self.cache.insert(key, CachedEntry {
|
||||||
|
protocol,
|
||||||
|
detected_at: now,
|
||||||
|
last_accessed_at: now,
|
||||||
|
last_probed_at: now,
|
||||||
|
h3_port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce a failure record's remaining cooldown to `target`, if it currently
|
||||||
|
/// has MORE than `target` remaining. Never increases cooldown.
|
||||||
|
fn reduce_cooldown_to(record: Option<&mut FailureRecord>, target: Duration) {
|
||||||
|
if let Some(r) = record {
|
||||||
|
let elapsed = r.failed_at.elapsed();
|
||||||
|
if elapsed < r.cooldown {
|
||||||
|
let remaining = r.cooldown - elapsed;
|
||||||
|
if remaining > target {
|
||||||
|
// Shrink cooldown so it expires in `target` from now
|
||||||
|
r.cooldown = elapsed + target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract suppression info from a failure record for metrics.
|
||||||
|
fn suppression_info(record: Option<&FailureRecord>) -> (bool, Option<u64>, Option<u32>) {
|
||||||
|
match record {
|
||||||
|
Some(r) => {
|
||||||
|
let elapsed = r.failed_at.elapsed();
|
||||||
|
let suppressed = elapsed < r.cooldown;
|
||||||
|
let remaining = if suppressed {
|
||||||
|
Some((r.cooldown - elapsed).as_secs())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
(suppressed, remaining, Some(r.consecutive_failures))
|
||||||
|
}
|
||||||
|
None => (false, None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background cleanup loop.
|
||||||
|
async fn cleanup_loop(
|
||||||
|
cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>>,
|
||||||
|
failures: Arc<DashMap<ProtocolCacheKey, FailureState>>,
|
||||||
|
) {
|
||||||
let mut interval = tokio::time::interval(PROTOCOL_CACHE_CLEANUP_INTERVAL);
|
let mut interval = tokio::time::interval(PROTOCOL_CACHE_CLEANUP_INTERVAL);
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
|
||||||
|
// Clean expired cache entries (sliding TTL based on last_accessed_at)
|
||||||
let expired: Vec<ProtocolCacheKey> = cache.iter()
|
let expired: Vec<ProtocolCacheKey> = cache.iter()
|
||||||
.filter(|entry| entry.value().detected_at.elapsed() >= PROTOCOL_CACHE_TTL)
|
.filter(|entry| entry.value().last_accessed_at.elapsed() >= PROTOCOL_CACHE_TTL)
|
||||||
.map(|entry| entry.key().clone())
|
.map(|entry| entry.key().clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -155,6 +593,31 @@ impl ProtocolCache {
|
|||||||
cache.remove(&key);
|
cache.remove(&key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean fully-expired failure entries
|
||||||
|
let expired_failures: Vec<ProtocolCacheKey> = failures.iter()
|
||||||
|
.filter(|entry| entry.value().all_expired())
|
||||||
|
.map(|entry| entry.key().clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !expired_failures.is_empty() {
|
||||||
|
debug!("Protocol cache cleanup: removing {} expired failure entries", expired_failures.len());
|
||||||
|
for key in expired_failures {
|
||||||
|
failures.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety net: cap failures map at 2× max entries
|
||||||
|
if failures.len() > PROTOCOL_CACHE_MAX_ENTRIES * 2 {
|
||||||
|
let oldest: Vec<ProtocolCacheKey> = failures.iter()
|
||||||
|
.filter(|e| e.value().all_expired())
|
||||||
|
.map(|e| e.key().clone())
|
||||||
|
.take(failures.len() - PROTOCOL_CACHE_MAX_ENTRIES)
|
||||||
|
.collect();
|
||||||
|
for key in oldest {
|
||||||
|
failures.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ pub struct ConnActivity {
|
|||||||
/// checks the backend's original response headers for Alt-Svc before our
|
/// checks the backend's original response headers for Alt-Svc before our
|
||||||
/// ResponseFilter injects its own. None when not in auto-detect mode or after H3 failure.
|
/// ResponseFilter injects its own. None when not in auto-detect mode or after H3 failure.
|
||||||
alt_svc_cache_key: Option<crate::protocol_cache::ProtocolCacheKey>,
|
alt_svc_cache_key: Option<crate::protocol_cache::ProtocolCacheKey>,
|
||||||
|
/// The upstream request path that triggered Alt-Svc discovery. Logged for traceability.
|
||||||
|
alt_svc_request_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnActivity {
|
impl ConnActivity {
|
||||||
@@ -58,6 +60,7 @@ impl ConnActivity {
|
|||||||
start: std::time::Instant::now(),
|
start: std::time::Instant::now(),
|
||||||
active_requests: None,
|
active_requests: None,
|
||||||
alt_svc_cache_key: None,
|
alt_svc_cache_key: None,
|
||||||
|
alt_svc_request_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,15 +72,16 @@ const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_s
|
|||||||
/// If no new request arrives within this duration, the connection is closed.
|
/// If no new request arrives within this duration, the connection is closed.
|
||||||
const DEFAULT_HTTP_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
const DEFAULT_HTTP_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Default HTTP max connection lifetime (1 hour).
|
||||||
|
/// HTTP connections are forcefully closed after this duration regardless of activity.
|
||||||
|
const DEFAULT_HTTP_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
/// Default WebSocket inactivity timeout (1 hour).
|
/// Default WebSocket inactivity timeout (1 hour).
|
||||||
const DEFAULT_WS_INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
|
const DEFAULT_WS_INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
/// Default WebSocket max lifetime (24 hours).
|
/// Default WebSocket max lifetime (24 hours).
|
||||||
const DEFAULT_WS_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(86400);
|
const DEFAULT_WS_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(86400);
|
||||||
|
|
||||||
/// Timeout for QUIC (H3) backend connections. Short because UDP is often firewalled.
|
|
||||||
const QUIC_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
|
||||||
|
|
||||||
/// Protocol decision for backend connection.
|
/// Protocol decision for backend connection.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum ProtocolDecision {
|
enum ProtocolDecision {
|
||||||
@@ -106,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.
|
/// Backend stream that can be either plain TCP or TLS-wrapped.
|
||||||
/// Used for `terminate-and-reencrypt` mode where the backend requires TLS.
|
/// Used for `terminate-and-reencrypt` mode where the backend requires TLS.
|
||||||
pub(crate) enum BackendStream {
|
pub(crate) enum BackendStream {
|
||||||
@@ -219,6 +285,8 @@ pub struct HttpProxyService {
|
|||||||
protocol_cache: Arc<crate::protocol_cache::ProtocolCache>,
|
protocol_cache: Arc<crate::protocol_cache::ProtocolCache>,
|
||||||
/// HTTP keep-alive idle timeout: close connection if no new request arrives within this duration.
|
/// HTTP keep-alive idle timeout: close connection if no new request arrives within this duration.
|
||||||
http_idle_timeout: std::time::Duration,
|
http_idle_timeout: std::time::Duration,
|
||||||
|
/// HTTP max connection lifetime: forcefully close connection after this duration regardless of activity.
|
||||||
|
http_max_lifetime: std::time::Duration,
|
||||||
/// WebSocket inactivity timeout (no data in either direction).
|
/// WebSocket inactivity timeout (no data in either direction).
|
||||||
ws_inactivity_timeout: std::time::Duration,
|
ws_inactivity_timeout: std::time::Duration,
|
||||||
/// WebSocket maximum connection lifetime.
|
/// WebSocket maximum connection lifetime.
|
||||||
@@ -245,6 +313,7 @@ impl HttpProxyService {
|
|||||||
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
||||||
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
||||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||||
|
http_max_lifetime: DEFAULT_HTTP_MAX_LIFETIME,
|
||||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||||
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||||
@@ -272,21 +341,24 @@ impl HttpProxyService {
|
|||||||
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
||||||
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
||||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||||
|
http_max_lifetime: DEFAULT_HTTP_MAX_LIFETIME,
|
||||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||||
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the HTTP keep-alive idle timeout, WebSocket inactivity timeout, and
|
/// Set the HTTP keep-alive idle timeout, HTTP max lifetime, WebSocket inactivity
|
||||||
/// WebSocket max lifetime from connection config values.
|
/// timeout, and WebSocket max lifetime from connection config values.
|
||||||
pub fn set_connection_timeouts(
|
pub fn set_connection_timeouts(
|
||||||
&mut self,
|
&mut self,
|
||||||
http_idle_timeout: std::time::Duration,
|
http_idle_timeout: std::time::Duration,
|
||||||
|
http_max_lifetime: std::time::Duration,
|
||||||
ws_inactivity_timeout: std::time::Duration,
|
ws_inactivity_timeout: std::time::Duration,
|
||||||
ws_max_lifetime: std::time::Duration,
|
ws_max_lifetime: std::time::Duration,
|
||||||
) {
|
) {
|
||||||
self.http_idle_timeout = http_idle_timeout;
|
self.http_idle_timeout = http_idle_timeout;
|
||||||
|
self.http_max_lifetime = http_max_lifetime;
|
||||||
self.ws_inactivity_timeout = ws_inactivity_timeout;
|
self.ws_inactivity_timeout = ws_inactivity_timeout;
|
||||||
self.ws_max_lifetime = ws_max_lifetime;
|
self.ws_max_lifetime = ws_max_lifetime;
|
||||||
}
|
}
|
||||||
@@ -311,6 +383,25 @@ impl HttpProxyService {
|
|||||||
self.protocol_cache.clear();
|
self.protocol_cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clean up expired entries in all per-route rate limiters.
|
||||||
|
/// Called from the background sampling task to prevent unbounded growth
|
||||||
|
/// when traffic stops after a burst of unique IPs.
|
||||||
|
pub fn cleanup_all_rate_limiters(&self) {
|
||||||
|
for entry in self.route_rate_limiters.iter() {
|
||||||
|
entry.value().cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the protocol cache for metrics/UI display.
|
||||||
|
pub fn protocol_cache_snapshot(&self) -> Vec<crate::protocol_cache::ProtocolCacheEntry> {
|
||||||
|
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.
|
/// Handle an incoming HTTP connection on a plain TCP stream.
|
||||||
pub async fn handle_connection(
|
pub async fn handle_connection(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
@@ -346,6 +437,7 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
// Capture timeouts before `self` is moved into the service closure.
|
// Capture timeouts before `self` is moved into the service closure.
|
||||||
let idle_timeout = self.http_idle_timeout;
|
let idle_timeout = self.http_idle_timeout;
|
||||||
|
let max_lifetime = self.http_max_lifetime;
|
||||||
|
|
||||||
// Activity tracker: updated at the START and END of each request.
|
// Activity tracker: updated at the START and END of each request.
|
||||||
// The idle watchdog checks this to determine if the connection is idle
|
// The idle watchdog checks this to determine if the connection is idle
|
||||||
@@ -354,10 +446,24 @@ impl HttpProxyService {
|
|||||||
let active_requests = Arc::new(AtomicU64::new(0));
|
let active_requests = Arc::new(AtomicU64::new(0));
|
||||||
let start = std::time::Instant::now();
|
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 la_inner = Arc::clone(&last_activity);
|
||||||
let ar_inner = Arc::clone(&active_requests);
|
let ar_inner = Arc::clone(&active_requests);
|
||||||
let cancel_inner = cancel.clone();
|
let cancel_inner = cancel.clone();
|
||||||
let service = hyper::service::service_fn(move |req: Request<Incoming>| {
|
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)
|
// Mark request start — RAII guard decrements on drop (panic-safe)
|
||||||
la_inner.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
|
la_inner.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||||
let req_guard = ActiveRequestGuard::new(Arc::clone(&ar_inner));
|
let req_guard = ActiveRequestGuard::new(Arc::clone(&ar_inner));
|
||||||
@@ -366,7 +472,7 @@ impl HttpProxyService {
|
|||||||
let cn = cancel_inner.clone();
|
let cn = cancel_inner.clone();
|
||||||
let la = Arc::clone(&la_inner);
|
let la = Arc::clone(&la_inner);
|
||||||
let st = start;
|
let st = start;
|
||||||
let ca = ConnActivity { last_activity: Arc::clone(&la_inner), start, active_requests: Some(Arc::clone(&ar_inner)), alt_svc_cache_key: None };
|
let ca = ConnActivity { last_activity: Arc::clone(&la_inner), start, active_requests: Some(Arc::clone(&ar_inner)), alt_svc_cache_key: None, alt_svc_request_url: None };
|
||||||
async move {
|
async move {
|
||||||
let req = req.map(|body| BoxBody::new(body));
|
let req = req.map(|body| BoxBody::new(body));
|
||||||
let result = svc.handle_request(req, peer, port, cn, ca).await;
|
let result = svc.handle_request(req, peer, port, cn, ca).await;
|
||||||
@@ -404,15 +510,23 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = async {
|
_ = async {
|
||||||
// Idle watchdog: check every 5s whether the connection has been idle
|
// Idle + lifetime watchdog: check every 5s whether the connection has been
|
||||||
// (no active requests AND no activity for idle_timeout).
|
// idle (no active requests AND no activity for idle_timeout) or exceeded
|
||||||
// This avoids killing long-running requests or upgraded connections.
|
// the max connection lifetime.
|
||||||
let check_interval = std::time::Duration::from_secs(5);
|
let check_interval = std::time::Duration::from_secs(5);
|
||||||
let mut last_seen = 0u64;
|
let mut last_seen = 0u64;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(check_interval).await;
|
tokio::time::sleep(check_interval).await;
|
||||||
|
|
||||||
// Never close while a request is in progress
|
// Check max connection lifetime (unconditional — even active connections
|
||||||
|
// must eventually be recycled to prevent resource accumulation).
|
||||||
|
if start.elapsed() >= max_lifetime {
|
||||||
|
debug!("HTTP connection exceeded max lifetime ({}s) from {}",
|
||||||
|
max_lifetime.as_secs(), peer_addr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never close for idleness while a request is in progress
|
||||||
if active_requests.load(Ordering::Relaxed) > 0 {
|
if active_requests.load(Ordering::Relaxed) > 0 {
|
||||||
last_seen = last_activity.load(Ordering::Relaxed);
|
last_seen = last_activity.load(Ordering::Relaxed);
|
||||||
continue;
|
continue;
|
||||||
@@ -429,7 +543,7 @@ impl HttpProxyService {
|
|||||||
last_seen = current;
|
last_seen = current;
|
||||||
}
|
}
|
||||||
} => {
|
} => {
|
||||||
debug!("HTTP connection idle timeout ({}s) from {}", idle_timeout.as_secs(), peer_addr);
|
debug!("HTTP connection timeout from {}", peer_addr);
|
||||||
conn.as_mut().graceful_shutdown();
|
conn.as_mut().graceful_shutdown();
|
||||||
// Give any in-flight work 5s to drain after graceful shutdown
|
// Give any in-flight work 5s to drain after graceful shutdown
|
||||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn).await;
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn).await;
|
||||||
@@ -512,6 +626,9 @@ impl HttpProxyService {
|
|||||||
let route_id = route_match.route.id.as_deref();
|
let route_id = route_match.route.id.as_deref();
|
||||||
let ip_str = ip_string; // reuse from above (avoid redundant to_string())
|
let ip_str = ip_string; // reuse from above (avoid redundant to_string())
|
||||||
self.metrics.record_http_request();
|
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)
|
// Apply request filters (IP check, rate limiting, auth)
|
||||||
if let Some(ref security) = route_match.route.security {
|
if let Some(ref security) = route_match.route.security {
|
||||||
@@ -592,6 +709,9 @@ impl HttpProxyService {
|
|||||||
.map(|p| p.as_str().eq_ignore_ascii_case("websocket"))
|
.map(|p| p.as_str().eq_ignore_ascii_case("websocket"))
|
||||||
.unwrap_or(false);
|
.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 {
|
if is_h1_websocket || is_h2_websocket {
|
||||||
let result = self.handle_websocket_upgrade(
|
let result = self.handle_websocket_upgrade(
|
||||||
req, peer_addr, &upstream, route_match.route, route_id, &upstream_key, cancel, &ip_str, is_h2_websocket,
|
req, peer_addr, &upstream, route_match.route, route_id, &upstream_key, cancel, &ip_str, is_h2_websocket,
|
||||||
@@ -701,6 +821,14 @@ impl HttpProxyService {
|
|||||||
port: upstream.port,
|
port: upstream.port,
|
||||||
requested_host: host.clone(),
|
requested_host: host.clone(),
|
||||||
};
|
};
|
||||||
|
// Save cached H3 port for within-request escalation (may be needed later
|
||||||
|
// if TCP connect fails and we escalate to H3 as a last resort)
|
||||||
|
let cached_h3_port = self.protocol_cache.get(&protocol_cache_key)
|
||||||
|
.and_then(|c| c.h3_port);
|
||||||
|
|
||||||
|
// Track whether this ALPN probe is a periodic re-probe (vs first-time detection)
|
||||||
|
let mut is_reprobe = false;
|
||||||
|
|
||||||
let protocol_decision = match backend_protocol_mode {
|
let protocol_decision = match backend_protocol_mode {
|
||||||
rustproxy_config::BackendProtocol::Http1 => ProtocolDecision::H1,
|
rustproxy_config::BackendProtocol::Http1 => ProtocolDecision::H1,
|
||||||
rustproxy_config::BackendProtocol::Http2 => ProtocolDecision::H2,
|
rustproxy_config::BackendProtocol::Http2 => ProtocolDecision::H2,
|
||||||
@@ -711,19 +839,40 @@ impl HttpProxyService {
|
|||||||
ProtocolDecision::H1
|
ProtocolDecision::H1
|
||||||
} else {
|
} else {
|
||||||
match self.protocol_cache.get(&protocol_cache_key) {
|
match self.protocol_cache.get(&protocol_cache_key) {
|
||||||
|
Some(cached) if cached.needs_reprobe => {
|
||||||
|
// Entry exists but 5+ minutes since last probe — force ALPN re-probe
|
||||||
|
// (only fires for H1/H2; H3 entries have needs_reprobe=false)
|
||||||
|
is_reprobe = true;
|
||||||
|
ProtocolDecision::AlpnProbe
|
||||||
|
}
|
||||||
Some(cached) => match cached.protocol {
|
Some(cached) => match cached.protocol {
|
||||||
crate::protocol_cache::DetectedProtocol::H3 => {
|
crate::protocol_cache::DetectedProtocol::H3 => {
|
||||||
if let Some(h3_port) = cached.h3_port {
|
if self.protocol_cache.is_suppressed(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3) {
|
||||||
|
// H3 cached but suppressed — fall back to ALPN probe
|
||||||
|
ProtocolDecision::AlpnProbe
|
||||||
|
} else if let Some(h3_port) = cached.h3_port {
|
||||||
ProtocolDecision::H3 { port: h3_port }
|
ProtocolDecision::H3 { port: h3_port }
|
||||||
} else {
|
} else {
|
||||||
// H3 cached but no port — fall back to ALPN probe
|
|
||||||
ProtocolDecision::AlpnProbe
|
ProtocolDecision::AlpnProbe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::protocol_cache::DetectedProtocol::H2 => ProtocolDecision::H2,
|
crate::protocol_cache::DetectedProtocol::H2 => {
|
||||||
|
if self.protocol_cache.is_suppressed(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H2) {
|
||||||
|
ProtocolDecision::H1
|
||||||
|
} else {
|
||||||
|
ProtocolDecision::H2
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::protocol_cache::DetectedProtocol::H1 => ProtocolDecision::H1,
|
crate::protocol_cache::DetectedProtocol::H1 => ProtocolDecision::H1,
|
||||||
},
|
},
|
||||||
None => ProtocolDecision::AlpnProbe,
|
None => {
|
||||||
|
// Cache miss — skip ALPN probe if H2 is suppressed
|
||||||
|
if self.protocol_cache.is_suppressed(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H2) {
|
||||||
|
ProtocolDecision::H1
|
||||||
|
} else {
|
||||||
|
ProtocolDecision::AlpnProbe
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -741,6 +890,7 @@ impl HttpProxyService {
|
|||||||
// the backend's original Alt-Svc header before ResponseFilter injects our own.
|
// the backend's original Alt-Svc header before ResponseFilter injects our own.
|
||||||
if is_auto_detect_mode {
|
if is_auto_detect_mode {
|
||||||
conn_activity.alt_svc_cache_key = Some(protocol_cache_key.clone());
|
conn_activity.alt_svc_cache_key = Some(protocol_cache_key.clone());
|
||||||
|
conn_activity.alt_svc_request_url = Some(upstream_path.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- H3 path: try QUIC connection before TCP ---
|
// --- H3 path: try QUIC connection before TCP ---
|
||||||
@@ -753,10 +903,10 @@ impl HttpProxyService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try H3 pool checkout first
|
// Try H3 pool checkout first
|
||||||
if let Some((quic_conn, _age)) = self.connection_pool.checkout_h3(&h3_pool_key) {
|
if let Some((pooled_sr, quic_conn, _age)) = self.connection_pool.checkout_h3(&h3_pool_key) {
|
||||||
self.metrics.backend_pool_hit(&upstream_key);
|
self.metrics.backend_pool_hit(&upstream_key);
|
||||||
let result = self.forward_h3(
|
let result = self.forward_h3(
|
||||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
quic_conn, Some(pooled_sr), parts, body, upstream_headers, &upstream_path,
|
||||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
).await;
|
).await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
@@ -769,15 +919,23 @@ impl HttpProxyService {
|
|||||||
self.metrics.backend_pool_miss(&upstream_key);
|
self.metrics.backend_pool_miss(&upstream_key);
|
||||||
self.metrics.backend_connection_opened(&upstream_key, std::time::Instant::now().elapsed());
|
self.metrics.backend_connection_opened(&upstream_key, std::time::Instant::now().elapsed());
|
||||||
let result = self.forward_h3(
|
let result = self.forward_h3(
|
||||||
quic_conn, parts, body, upstream_headers, &upstream_path,
|
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||||
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
).await;
|
).await;
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(backend = %upstream_key, error = %e,
|
warn!(backend = %upstream_key, domain = %domain_str, error = %e,
|
||||||
"H3 backend connect failed, falling back to H2/H1");
|
"H3 backend connect failed, falling back to H2/H1");
|
||||||
|
// Record failure with escalating cooldown — prevents Alt-Svc
|
||||||
|
// from re-upgrading to H3 during cooldown period
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
protocol_cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H3,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Suppress Alt-Svc caching for the fallback to prevent re-caching H3
|
// Suppress Alt-Svc caching for the fallback to prevent re-caching H3
|
||||||
// from our own injected Alt-Svc header or a stale backend Alt-Svc
|
// from our own injected Alt-Svc header or a stale backend Alt-Svc
|
||||||
conn_activity.alt_svc_cache_key = None;
|
conn_activity.alt_svc_cache_key = None;
|
||||||
@@ -860,7 +1018,7 @@ impl HttpProxyService {
|
|||||||
let alpn = tls.get_ref().1.alpn_protocol();
|
let alpn = tls.get_ref().1.alpn_protocol();
|
||||||
let is_h2 = alpn.map(|p| p == b"h2").unwrap_or(false);
|
let is_h2 = alpn.map(|p| p == b"h2").unwrap_or(false);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result (or update existing entry for re-probes)
|
||||||
let cache_key = crate::protocol_cache::ProtocolCacheKey {
|
let cache_key = crate::protocol_cache::ProtocolCacheKey {
|
||||||
host: upstream.host.clone(),
|
host: upstream.host.clone(),
|
||||||
port: upstream.port,
|
port: upstream.port,
|
||||||
@@ -871,13 +1029,18 @@ impl HttpProxyService {
|
|||||||
} else {
|
} else {
|
||||||
crate::protocol_cache::DetectedProtocol::H1
|
crate::protocol_cache::DetectedProtocol::H1
|
||||||
};
|
};
|
||||||
self.protocol_cache.insert(cache_key, detected);
|
if is_reprobe {
|
||||||
|
self.protocol_cache.update_probe_result(&cache_key, detected, "periodic ALPN re-probe");
|
||||||
|
} else {
|
||||||
|
self.protocol_cache.insert(cache_key, detected, "initial ALPN detection");
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
backend = %upstream_key,
|
backend = %upstream_key,
|
||||||
domain = %domain_str,
|
domain = %domain_str,
|
||||||
protocol = if is_h2 { "h2" } else { "h1" },
|
protocol = if is_h2 { "h2" } else { "h1" },
|
||||||
connect_time_ms = %connect_start.elapsed().as_millis(),
|
connect_time_ms = %connect_start.elapsed().as_millis(),
|
||||||
|
reprobe = is_reprobe,
|
||||||
"Backend protocol detected via ALPN"
|
"Backend protocol detected via ALPN"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -899,6 +1062,38 @@ impl HttpProxyService {
|
|||||||
);
|
);
|
||||||
self.metrics.backend_connect_error(&upstream_key);
|
self.metrics.backend_connect_error(&upstream_key);
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
|
||||||
|
// --- Within-request escalation: try H3 via QUIC if retryable ---
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
if let Some(h3_port) = cached_h3_port {
|
||||||
|
if self.protocol_cache.can_retry(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3) {
|
||||||
|
self.protocol_cache.record_retry_attempt(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
debug!(backend = %upstream_key, domain = %domain_str, "TLS connect failed — escalating to H3");
|
||||||
|
match self.connect_quic_backend(&upstream.host, h3_port).await {
|
||||||
|
Ok(quic_conn) => {
|
||||||
|
self.protocol_cache.clear_failure(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
self.protocol_cache.insert_h3(protocol_cache_key.clone(), h3_port, "recovery — TLS failed, H3 succeeded");
|
||||||
|
let h3_pool_key = crate::connection_pool::PoolKey {
|
||||||
|
host: upstream.host.clone(), port: h3_port, use_tls: true,
|
||||||
|
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||||
|
};
|
||||||
|
let result = self.forward_h3(
|
||||||
|
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||||
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
|
).await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Err(e3) => {
|
||||||
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
|
self.protocol_cache.record_failure(protocol_cache_key.clone(), crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All protocols failed — evict cache entry
|
||||||
|
self.protocol_cache.evict(&protocol_cache_key);
|
||||||
|
}
|
||||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend TLS unavailable"));
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend TLS unavailable"));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -910,6 +1105,38 @@ impl HttpProxyService {
|
|||||||
);
|
);
|
||||||
self.metrics.backend_connect_error(&upstream_key);
|
self.metrics.backend_connect_error(&upstream_key);
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
|
||||||
|
// --- Within-request escalation: try H3 via QUIC if retryable ---
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
if let Some(h3_port) = cached_h3_port {
|
||||||
|
if self.protocol_cache.can_retry(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3) {
|
||||||
|
self.protocol_cache.record_retry_attempt(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
debug!(backend = %upstream_key, domain = %domain_str, "TLS connect timeout — escalating to H3");
|
||||||
|
match self.connect_quic_backend(&upstream.host, h3_port).await {
|
||||||
|
Ok(quic_conn) => {
|
||||||
|
self.protocol_cache.clear_failure(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
self.protocol_cache.insert_h3(protocol_cache_key.clone(), h3_port, "recovery — TLS timeout, H3 succeeded");
|
||||||
|
let h3_pool_key = crate::connection_pool::PoolKey {
|
||||||
|
host: upstream.host.clone(), port: h3_port, use_tls: true,
|
||||||
|
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||||
|
};
|
||||||
|
let result = self.forward_h3(
|
||||||
|
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||||
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
|
).await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Err(e3) => {
|
||||||
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
|
self.protocol_cache.record_failure(protocol_cache_key.clone(), crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All protocols failed — evict cache entry
|
||||||
|
self.protocol_cache.evict(&protocol_cache_key);
|
||||||
|
}
|
||||||
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend TLS connect timeout"));
|
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend TLS connect timeout"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,6 +1164,38 @@ impl HttpProxyService {
|
|||||||
);
|
);
|
||||||
self.metrics.backend_connect_error(&upstream_key);
|
self.metrics.backend_connect_error(&upstream_key);
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
|
||||||
|
// --- Within-request escalation: try H3 via QUIC if retryable ---
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
if let Some(h3_port) = cached_h3_port {
|
||||||
|
if self.protocol_cache.can_retry(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3) {
|
||||||
|
self.protocol_cache.record_retry_attempt(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
debug!(backend = %upstream_key, domain = %domain_str, "TCP connect failed — escalating to H3");
|
||||||
|
match self.connect_quic_backend(&upstream.host, h3_port).await {
|
||||||
|
Ok(quic_conn) => {
|
||||||
|
self.protocol_cache.clear_failure(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
self.protocol_cache.insert_h3(protocol_cache_key.clone(), h3_port, "recovery — TCP failed, H3 succeeded");
|
||||||
|
let h3_pool_key = crate::connection_pool::PoolKey {
|
||||||
|
host: upstream.host.clone(), port: h3_port, use_tls: true,
|
||||||
|
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||||
|
};
|
||||||
|
let result = self.forward_h3(
|
||||||
|
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||||
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
|
).await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Err(e3) => {
|
||||||
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
|
self.protocol_cache.record_failure(protocol_cache_key.clone(), crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All protocols failed — evict cache entry
|
||||||
|
self.protocol_cache.evict(&protocol_cache_key);
|
||||||
|
}
|
||||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -948,6 +1207,38 @@ impl HttpProxyService {
|
|||||||
);
|
);
|
||||||
self.metrics.backend_connect_error(&upstream_key);
|
self.metrics.backend_connect_error(&upstream_key);
|
||||||
self.upstream_selector.connection_ended(&upstream_key);
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
|
||||||
|
// --- Within-request escalation: try H3 via QUIC if retryable ---
|
||||||
|
if is_auto_detect_mode {
|
||||||
|
if let Some(h3_port) = cached_h3_port {
|
||||||
|
if self.protocol_cache.can_retry(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3) {
|
||||||
|
self.protocol_cache.record_retry_attempt(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
debug!(backend = %upstream_key, domain = %domain_str, "TCP connect timeout — escalating to H3");
|
||||||
|
match self.connect_quic_backend(&upstream.host, h3_port).await {
|
||||||
|
Ok(quic_conn) => {
|
||||||
|
self.protocol_cache.clear_failure(&protocol_cache_key, crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
self.protocol_cache.insert_h3(protocol_cache_key.clone(), h3_port, "recovery — TCP timeout, H3 succeeded");
|
||||||
|
let h3_pool_key = crate::connection_pool::PoolKey {
|
||||||
|
host: upstream.host.clone(), port: h3_port, use_tls: true,
|
||||||
|
protocol: crate::connection_pool::PoolProtocol::H3,
|
||||||
|
};
|
||||||
|
let result = self.forward_h3(
|
||||||
|
quic_conn, None, parts, body, upstream_headers, &upstream_path,
|
||||||
|
route_match.route, route_id, &ip_str, &h3_pool_key, domain_str, &conn_activity, &upstream_key,
|
||||||
|
).await;
|
||||||
|
self.upstream_selector.connection_ended(&upstream_key);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Err(e3) => {
|
||||||
|
debug!(backend = %upstream_key, error = %e3, "H3 escalation also failed");
|
||||||
|
self.protocol_cache.record_failure(protocol_cache_key.clone(), crate::protocol_cache::DetectedProtocol::H3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All protocols failed — evict cache entry
|
||||||
|
self.protocol_cache.evict(&protocol_cache_key);
|
||||||
|
}
|
||||||
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend connect timeout"));
|
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend connect timeout"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1029,11 +1320,18 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(async move {
|
{
|
||||||
if let Err(e) = conn.await {
|
let driver_metrics = Arc::clone(&self.metrics);
|
||||||
debug!("Upstream connection error: {}", e);
|
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
|
self.forward_h1_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, domain, conn_activity, backend_key).await
|
||||||
}
|
}
|
||||||
@@ -1154,7 +1452,10 @@ impl HttpProxyService {
|
|||||||
let pool = Arc::clone(&self.connection_pool);
|
let pool = Arc::clone(&self.connection_pool);
|
||||||
let key = pool_key.clone();
|
let key = pool_key.clone();
|
||||||
let gen = Arc::clone(&gen_holder);
|
let gen = Arc::clone(&gen_holder);
|
||||||
|
let driver_metrics = Arc::clone(&self.metrics);
|
||||||
tokio::spawn(async move {
|
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 {
|
if let Err(e) = conn.await {
|
||||||
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
||||||
}
|
}
|
||||||
@@ -1416,7 +1717,12 @@ impl HttpProxyService {
|
|||||||
port: upstream.port,
|
port: upstream.port,
|
||||||
requested_host: requested_host.clone(),
|
requested_host: requested_host.clone(),
|
||||||
};
|
};
|
||||||
self.protocol_cache.insert(cache_key, crate::protocol_cache::DetectedProtocol::H1);
|
// Record H2 failure (escalating cooldown) before downgrading cache to H1
|
||||||
|
self.protocol_cache.record_failure(
|
||||||
|
cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H2,
|
||||||
|
);
|
||||||
|
self.protocol_cache.insert(cache_key.clone(), crate::protocol_cache::DetectedProtocol::H1, "H2 handshake timeout — downgrade");
|
||||||
|
|
||||||
match self.reconnect_backend(upstream, domain, backend_key).await {
|
match self.reconnect_backend(upstream, domain, backend_key).await {
|
||||||
Some(fallback_backend) => {
|
Some(fallback_backend) => {
|
||||||
@@ -1435,6 +1741,8 @@ impl HttpProxyService {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
// H2 failed and H1 reconnect also failed — evict cache
|
||||||
|
self.protocol_cache.evict(&cache_key);
|
||||||
Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable after H2 timeout fallback"))
|
Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable after H2 timeout fallback"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1446,7 +1754,10 @@ impl HttpProxyService {
|
|||||||
let pool = Arc::clone(&self.connection_pool);
|
let pool = Arc::clone(&self.connection_pool);
|
||||||
let key = pool_key.clone();
|
let key = pool_key.clone();
|
||||||
let gen = Arc::clone(&gen_holder);
|
let gen = Arc::clone(&gen_holder);
|
||||||
|
let driver_metrics = Arc::clone(&self.metrics);
|
||||||
tokio::spawn(async move {
|
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 {
|
if let Err(e) = conn.await {
|
||||||
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
warn!("HTTP/2 upstream connection error: {} ({:?})", e, e);
|
||||||
}
|
}
|
||||||
@@ -1549,13 +1860,17 @@ impl HttpProxyService {
|
|||||||
self.metrics.backend_h2_failure(backend_key);
|
self.metrics.backend_h2_failure(backend_key);
|
||||||
self.metrics.backend_handshake_error(backend_key);
|
self.metrics.backend_handshake_error(backend_key);
|
||||||
|
|
||||||
// Update cache to H1 so subsequent requests skip H2
|
// Record H2 failure (escalating cooldown) and downgrade cache to H1
|
||||||
let cache_key = crate::protocol_cache::ProtocolCacheKey {
|
let cache_key = crate::protocol_cache::ProtocolCacheKey {
|
||||||
host: upstream.host.clone(),
|
host: upstream.host.clone(),
|
||||||
port: upstream.port,
|
port: upstream.port,
|
||||||
requested_host: requested_host.clone(),
|
requested_host: requested_host.clone(),
|
||||||
};
|
};
|
||||||
self.protocol_cache.insert(cache_key, crate::protocol_cache::DetectedProtocol::H1);
|
self.protocol_cache.record_failure(
|
||||||
|
cache_key.clone(),
|
||||||
|
crate::protocol_cache::DetectedProtocol::H2,
|
||||||
|
);
|
||||||
|
self.protocol_cache.insert(cache_key.clone(), crate::protocol_cache::DetectedProtocol::H1, "H2 handshake error — downgrade");
|
||||||
|
|
||||||
// Reconnect for H1 (the original io was consumed by the failed h2 handshake)
|
// Reconnect for H1 (the original io was consumed by the failed h2 handshake)
|
||||||
match self.reconnect_backend(upstream, domain, backend_key).await {
|
match self.reconnect_backend(upstream, domain, backend_key).await {
|
||||||
@@ -1576,6 +1891,8 @@ impl HttpProxyService {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
// H2 failed and H1 reconnect also failed — evict cache
|
||||||
|
self.protocol_cache.evict(&cache_key);
|
||||||
Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable after H2 fallback"))
|
Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable after H2 fallback"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1610,11 +1927,18 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(async move {
|
{
|
||||||
if let Err(e) = conn.await {
|
let driver_metrics = Arc::clone(&self.metrics);
|
||||||
debug!("H1 fallback: upstream connection error: {}", e);
|
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()
|
let mut upstream_req = Request::builder()
|
||||||
.method(method)
|
.method(method)
|
||||||
@@ -1791,8 +2115,10 @@ impl HttpProxyService {
|
|||||||
if let Some(ref cache_key) = conn_activity.alt_svc_cache_key {
|
if let Some(ref cache_key) = conn_activity.alt_svc_cache_key {
|
||||||
if let Some(alt_svc) = resp_parts.headers.get("alt-svc").and_then(|v| v.to_str().ok()) {
|
if let Some(alt_svc) = resp_parts.headers.get("alt-svc").and_then(|v| v.to_str().ok()) {
|
||||||
if let Some(h3_port) = parse_alt_svc_h3_port(alt_svc) {
|
if let Some(h3_port) = parse_alt_svc_h3_port(alt_svc) {
|
||||||
debug!(h3_port, "Backend advertises H3 via Alt-Svc");
|
let url = conn_activity.alt_svc_request_url.as_deref().unwrap_or("-");
|
||||||
self.protocol_cache.insert_h3(cache_key.clone(), h3_port);
|
debug!(h3_port, url, "Backend advertises H3 via Alt-Svc");
|
||||||
|
let reason = format!("Alt-Svc response header ({})", url);
|
||||||
|
self.protocol_cache.insert_h3(cache_key.clone(), h3_port, &reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2160,6 +2486,11 @@ impl HttpProxyService {
|
|||||||
selector: upstream_selector,
|
selector: upstream_selector,
|
||||||
key: upstream_key_owned.clone(),
|
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 {
|
let client_upgraded = match on_client_upgrade.await {
|
||||||
Ok(upgraded) => upgraded,
|
Ok(upgraded) => upgraded,
|
||||||
@@ -2546,7 +2877,12 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
let quic_crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls_config)
|
let quic_crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls_config)
|
||||||
.expect("Failed to create QUIC client crypto config");
|
.expect("Failed to create QUIC client crypto config");
|
||||||
let client_config = quinn::ClientConfig::new(Arc::new(quic_crypto));
|
|
||||||
|
// Tune QUIC transport to match H2 flow-control: 2 MB per-stream receive window.
|
||||||
|
let mut transport = quinn::TransportConfig::default();
|
||||||
|
transport.stream_receive_window(quinn::VarInt::from_u32(2 * 1024 * 1024));
|
||||||
|
let mut client_config = quinn::ClientConfig::new(Arc::new(quic_crypto));
|
||||||
|
client_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())
|
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())
|
||||||
.expect("Failed to create QUIC client endpoint");
|
.expect("Failed to create QUIC client endpoint");
|
||||||
@@ -2568,8 +2904,8 @@ impl HttpProxyService {
|
|||||||
let server_name = host.to_string();
|
let server_name = host.to_string();
|
||||||
let connecting = self.quinn_client_endpoint.connect(addr, &server_name)?;
|
let connecting = self.quinn_client_endpoint.connect(addr, &server_name)?;
|
||||||
|
|
||||||
let connection = tokio::time::timeout(QUIC_CONNECT_TIMEOUT, connecting).await
|
let connection = tokio::time::timeout(self.connect_timeout, connecting).await
|
||||||
.map_err(|_| "QUIC connect timeout (3s)")??;
|
.map_err(|_| format!("QUIC connect timeout ({:?}) for {}", self.connect_timeout, host))??;
|
||||||
|
|
||||||
debug!("QUIC backend connection established to {}:{}", host, port);
|
debug!("QUIC backend connection established to {}:{}", host, port);
|
||||||
Ok(connection)
|
Ok(connection)
|
||||||
@@ -2579,6 +2915,7 @@ impl HttpProxyService {
|
|||||||
async fn forward_h3(
|
async fn forward_h3(
|
||||||
&self,
|
&self,
|
||||||
quic_conn: quinn::Connection,
|
quic_conn: quinn::Connection,
|
||||||
|
pooled_sender: Option<h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>>,
|
||||||
parts: hyper::http::request::Parts,
|
parts: hyper::http::request::Parts,
|
||||||
body: BoxBody<Bytes, hyper::Error>,
|
body: BoxBody<Bytes, hyper::Error>,
|
||||||
upstream_headers: hyper::HeaderMap,
|
upstream_headers: hyper::HeaderMap,
|
||||||
@@ -2591,33 +2928,45 @@ impl HttpProxyService {
|
|||||||
conn_activity: &ConnActivity,
|
conn_activity: &ConnActivity,
|
||||||
backend_key: &str,
|
backend_key: &str,
|
||||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||||
let h3_quinn_conn = h3_quinn::Connection::new(quic_conn.clone());
|
// Obtain the h3 SendRequest handle: skip handshake + driver on pool hit.
|
||||||
let (mut driver, mut send_request) = match h3::client::builder()
|
let (mut send_request, gen_holder) = if let Some(sr) = pooled_sender {
|
||||||
.send_grease(false)
|
// Pool hit — reuse existing h3 session, no SETTINGS round-trip
|
||||||
.build(h3_quinn_conn)
|
(sr, None)
|
||||||
.await
|
} else {
|
||||||
{
|
// Fresh QUIC connection — full h3 handshake + driver spawn
|
||||||
Ok(pair) => pair,
|
let h3_quinn_conn = h3_quinn::Connection::new(quic_conn.clone());
|
||||||
Err(e) => {
|
let (mut driver, sr) = match h3::client::builder()
|
||||||
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
.send_grease(false)
|
||||||
self.metrics.backend_handshake_error(backend_key);
|
.build(h3_quinn_conn)
|
||||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 handshake failed"));
|
.await
|
||||||
}
|
{
|
||||||
};
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
||||||
|
self.metrics.backend_handshake_error(backend_key);
|
||||||
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 handshake failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Spawn the h3 connection driver
|
let gen_holder = Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX));
|
||||||
let driver_pool = Arc::clone(&self.connection_pool);
|
{
|
||||||
let driver_pool_key = pool_key.clone();
|
let driver_pool = Arc::clone(&self.connection_pool);
|
||||||
let gen_holder = Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX));
|
let driver_pool_key = pool_key.clone();
|
||||||
let driver_gen = Arc::clone(&gen_holder);
|
let driver_gen = Arc::clone(&gen_holder);
|
||||||
tokio::spawn(async move {
|
let driver_metrics = Arc::clone(&self.metrics);
|
||||||
let close_err = std::future::poll_fn(|cx| driver.poll_close(cx)).await;
|
tokio::spawn(async move {
|
||||||
debug!("H3 connection driver closed: {:?}", close_err);
|
// Track backend H3 connection for the driver's lifetime
|
||||||
let g = driver_gen.load(std::sync::atomic::Ordering::Relaxed);
|
let _proto_guard = ProtocolGuard::backend(driver_metrics, "h3");
|
||||||
if g != u64::MAX {
|
let close_err = std::future::poll_fn(|cx| driver.poll_close(cx)).await;
|
||||||
driver_pool.remove_h3_if_generation(&driver_pool_key, g);
|
debug!("H3 connection driver closed: {:?}", close_err);
|
||||||
|
let g = driver_gen.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if g != u64::MAX {
|
||||||
|
driver_pool.remove_h3_if_generation(&driver_pool_key, g);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
(sr, Some(gen_holder))
|
||||||
|
};
|
||||||
|
|
||||||
// Build the H3 request
|
// Build the H3 request
|
||||||
let uri = hyper::Uri::builder()
|
let uri = hyper::Uri::builder()
|
||||||
@@ -2647,7 +2996,7 @@ impl HttpProxyService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stream request body
|
// Stream request body (zero-copy: into_data yields owned Bytes)
|
||||||
let rid: Option<Arc<str>> = route_id.map(Arc::from);
|
let rid: Option<Arc<str>> = route_id.map(Arc::from);
|
||||||
let sip: Arc<str> = Arc::from(source_ip);
|
let sip: Arc<str> = Arc::from(source_ip);
|
||||||
|
|
||||||
@@ -2657,9 +3006,9 @@ impl HttpProxyService {
|
|||||||
while let Some(frame) = body.frame().await {
|
while let Some(frame) = body.frame().await {
|
||||||
match frame {
|
match frame {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
if let Some(data) = frame.data_ref() {
|
if let Ok(data) = frame.into_data() {
|
||||||
self.metrics.record_bytes(data.len() as u64, 0, rid.as_deref(), Some(&sip));
|
self.metrics.record_bytes(data.len() as u64, 0, rid.as_deref(), Some(&sip));
|
||||||
if let Err(e) = stream.send_data(Bytes::copy_from_slice(data)).await {
|
if let Err(e) = stream.send_data(data).await {
|
||||||
error!(backend = %backend_key, error = %e, "H3 send_data failed");
|
error!(backend = %backend_key, error = %e, "H3 send_data failed");
|
||||||
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 body send failed"));
|
return Ok(error_response(StatusCode::BAD_GATEWAY, "H3 body send failed"));
|
||||||
}
|
}
|
||||||
@@ -2701,8 +3050,23 @@ impl HttpProxyService {
|
|||||||
ResponseFilter::apply_headers(route, headers, None);
|
ResponseFilter::apply_headers(route, headers, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream response body back via an adapter
|
// Stream response body back via unfold — correctly preserves waker across polls
|
||||||
let h3_body = H3ClientResponseBody { stream };
|
let body_stream = futures::stream::unfold(stream, |mut s| async move {
|
||||||
|
match s.recv_data().await {
|
||||||
|
Ok(Some(mut buf)) => {
|
||||||
|
use bytes::Buf;
|
||||||
|
let data = buf.copy_to_bytes(buf.remaining());
|
||||||
|
Some((Ok::<_, hyper::Error>(http_body::Frame::data(data)), s))
|
||||||
|
}
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("H3 response body recv error: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let h3_body = http_body_util::StreamBody::new(body_stream);
|
||||||
|
|
||||||
let counting_body = CountingBody::new(
|
let counting_body = CountingBody::new(
|
||||||
h3_body,
|
h3_body,
|
||||||
Arc::clone(&self.metrics),
|
Arc::clone(&self.metrics),
|
||||||
@@ -2719,10 +3083,16 @@ impl HttpProxyService {
|
|||||||
|
|
||||||
let body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_body);
|
let body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_body);
|
||||||
|
|
||||||
// Register connection in pool on success
|
// Register connection in pool on success (fresh connections only)
|
||||||
if status != StatusCode::BAD_GATEWAY {
|
if status != StatusCode::BAD_GATEWAY {
|
||||||
let g = self.connection_pool.register_h3(pool_key.clone(), quic_conn);
|
if let Some(gh) = gen_holder {
|
||||||
gen_holder.store(g, std::sync::atomic::Ordering::Relaxed);
|
let g = self.connection_pool.register_h3(
|
||||||
|
pool_key.clone(),
|
||||||
|
quic_conn,
|
||||||
|
send_request,
|
||||||
|
);
|
||||||
|
gh.store(g, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.metrics.set_backend_protocol(backend_key, "h3");
|
self.metrics.set_backend_protocol(backend_key, "h3");
|
||||||
@@ -2751,41 +3121,6 @@ fn parse_alt_svc_h3_port(header_value: &str) -> Option<u16> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response body adapter for H3 client responses.
|
|
||||||
/// Reads data from the h3 `RequestStream` recv side and presents it as an `http_body::Body`.
|
|
||||||
struct H3ClientResponseBody {
|
|
||||||
stream: h3::client::RequestStream<h3_quinn::BidiStream<Bytes>, Bytes>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl http_body::Body for H3ClientResponseBody {
|
|
||||||
type Data = Bytes;
|
|
||||||
type Error = hyper::Error;
|
|
||||||
|
|
||||||
fn poll_frame(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
_cx: &mut Context<'_>,
|
|
||||||
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
|
||||||
// h3's recv_data is async, so we need to poll it manually.
|
|
||||||
// Use a small future to poll the recv_data call.
|
|
||||||
use std::future::Future;
|
|
||||||
let mut fut = Box::pin(self.stream.recv_data());
|
|
||||||
match fut.as_mut().poll(_cx) {
|
|
||||||
Poll::Ready(Ok(Some(mut buf))) => {
|
|
||||||
use bytes::Buf;
|
|
||||||
let data = Bytes::copy_from_slice(buf.chunk());
|
|
||||||
buf.advance(buf.remaining());
|
|
||||||
Poll::Ready(Some(Ok(http_body::Frame::data(data))))
|
|
||||||
}
|
|
||||||
Poll::Ready(Ok(None)) => Poll::Ready(None),
|
|
||||||
Poll::Ready(Err(e)) => {
|
|
||||||
warn!("H3 response body recv error: {}", e);
|
|
||||||
Poll::Ready(None)
|
|
||||||
}
|
|
||||||
Poll::Pending => Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insecure certificate verifier for backend TLS connections (fallback only).
|
/// Insecure certificate verifier for backend TLS connections (fallback only).
|
||||||
/// The production path uses the shared config from tls_handler which has the same
|
/// The production path uses the shared config from tls_handler which has the same
|
||||||
/// behavior but with session resumption across all outbound connections.
|
/// behavior but with session resumption across all outbound connections.
|
||||||
@@ -2854,6 +3189,7 @@ impl Default for HttpProxyService {
|
|||||||
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
connection_pool: Arc::new(crate::connection_pool::ConnectionPool::new()),
|
||||||
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
protocol_cache: Arc::new(crate::protocol_cache::ProtocolCache::new()),
|
||||||
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
http_idle_timeout: DEFAULT_HTTP_IDLE_TIMEOUT,
|
||||||
|
http_max_lifetime: DEFAULT_HTTP_MAX_LIFETIME,
|
||||||
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
ws_inactivity_timeout: DEFAULT_WS_INACTIVITY_TIMEOUT,
|
||||||
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
ws_max_lifetime: DEFAULT_WS_MAX_LIFETIME,
|
||||||
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
quinn_client_endpoint: Arc::new(Self::create_quinn_client_endpoint()),
|
||||||
|
|||||||
@@ -35,13 +35,17 @@ impl RequestFilter {
|
|||||||
let client_ip = peer_addr.ip();
|
let client_ip = peer_addr.ip();
|
||||||
let request_path = req.uri().path();
|
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() {
|
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
|
||||||
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
|
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
|
||||||
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
|
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
|
||||||
let filter = IpFilter::new(allow, block);
|
let filter = IpFilter::new(allow, block);
|
||||||
let normalized = IpFilter::normalize_ip(&client_ip);
|
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"));
|
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).
|
/// 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.
|
/// 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() {
|
if security.ip_allow_list.is_some() || security.ip_block_list.is_some() {
|
||||||
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
|
let allow = security.ip_allow_list.as_deref().unwrap_or(&[]);
|
||||||
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
|
let block = security.ip_block_list.as_deref().unwrap_or(&[]);
|
||||||
let filter = IpFilter::new(allow, block);
|
let filter = IpFilter::new(allow, block);
|
||||||
let normalized = IpFilter::normalize_ip(client_ip);
|
let normalized = IpFilter::normalize_ip(client_ip);
|
||||||
filter.is_allowed(&normalized)
|
filter.is_allowed_for_domain(&normalized, domain)
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -31,6 +31,11 @@ pub struct Metrics {
|
|||||||
pub total_udp_sessions: u64,
|
pub total_udp_sessions: u64,
|
||||||
pub total_datagrams_in: u64,
|
pub total_datagrams_in: u64,
|
||||||
pub total_datagrams_out: u64,
|
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.
|
/// Per-route metrics.
|
||||||
@@ -57,6 +62,8 @@ pub struct IpMetrics {
|
|||||||
pub bytes_out: u64,
|
pub bytes_out: u64,
|
||||||
pub throughput_in_bytes_per_sec: u64,
|
pub throughput_in_bytes_per_sec: u64,
|
||||||
pub throughput_out_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").
|
/// Per-backend metrics (keyed by "host:port").
|
||||||
@@ -76,6 +83,44 @@ pub struct BackendMetrics {
|
|||||||
pub h2_failures: u64,
|
pub h2_failures: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Protocol cache entry for metrics/UI display.
|
||||||
|
/// Populated from the HTTP proxy service's protocol detection cache.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProtocolCacheEntryMetric {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub domain: Option<String>,
|
||||||
|
pub protocol: String,
|
||||||
|
pub h3_port: Option<u16>,
|
||||||
|
pub age_secs: u64,
|
||||||
|
pub last_accessed_secs: u64,
|
||||||
|
pub last_probed_secs: u64,
|
||||||
|
pub h2_suppressed: bool,
|
||||||
|
pub h3_suppressed: bool,
|
||||||
|
pub h2_cooldown_remaining_secs: Option<u64>,
|
||||||
|
pub h3_cooldown_remaining_secs: Option<u64>,
|
||||||
|
pub h2_consecutive_failures: Option<u32>,
|
||||||
|
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.
|
/// Statistics snapshot.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -96,6 +141,9 @@ const MAX_IPS_IN_SNAPSHOT: usize = 100;
|
|||||||
/// Maximum number of backends to include in a snapshot (top by total connections).
|
/// Maximum number of backends to include in a snapshot (top by total connections).
|
||||||
const MAX_BACKENDS_IN_SNAPSHOT: usize = 100;
|
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.
|
/// Metrics collector tracking connections and throughput.
|
||||||
///
|
///
|
||||||
/// Design: The hot path (`record_bytes`) is entirely lock-free — it only touches
|
/// Design: The hot path (`record_bytes`) is entirely lock-free — it only touches
|
||||||
@@ -122,6 +170,10 @@ pub struct MetricsCollector {
|
|||||||
ip_bytes_out: DashMap<String, AtomicU64>,
|
ip_bytes_out: DashMap<String, AtomicU64>,
|
||||||
ip_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
|
ip_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
|
||||||
ip_throughput: DashMap<String, Mutex<ThroughputTracker>>,
|
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") ──
|
// ── Per-backend tracking (keyed by "host:port") ──
|
||||||
backend_active: DashMap<String, AtomicU64>,
|
backend_active: DashMap<String, AtomicU64>,
|
||||||
@@ -147,6 +199,30 @@ pub struct MetricsCollector {
|
|||||||
total_datagrams_in: AtomicU64,
|
total_datagrams_in: AtomicU64,
|
||||||
total_datagrams_out: 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) ──
|
// ── Lock-free pending throughput counters (hot path) ──
|
||||||
global_pending_tp_in: AtomicU64,
|
global_pending_tp_in: AtomicU64,
|
||||||
global_pending_tp_out: AtomicU64,
|
global_pending_tp_out: AtomicU64,
|
||||||
@@ -180,6 +256,7 @@ impl MetricsCollector {
|
|||||||
ip_bytes_out: DashMap::new(),
|
ip_bytes_out: DashMap::new(),
|
||||||
ip_pending_tp: DashMap::new(),
|
ip_pending_tp: DashMap::new(),
|
||||||
ip_throughput: DashMap::new(),
|
ip_throughput: DashMap::new(),
|
||||||
|
ip_domain_requests: DashMap::new(),
|
||||||
backend_active: DashMap::new(),
|
backend_active: DashMap::new(),
|
||||||
backend_total: DashMap::new(),
|
backend_total: DashMap::new(),
|
||||||
backend_protocol: DashMap::new(),
|
backend_protocol: DashMap::new(),
|
||||||
@@ -198,6 +275,26 @@ impl MetricsCollector {
|
|||||||
total_http_requests: AtomicU64::new(0),
|
total_http_requests: AtomicU64::new(0),
|
||||||
pending_http_requests: AtomicU64::new(0),
|
pending_http_requests: AtomicU64::new(0),
|
||||||
http_request_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)),
|
http_request_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)),
|
||||||
|
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_in: AtomicU64::new(0),
|
||||||
global_pending_tp_out: AtomicU64::new(0),
|
global_pending_tp_out: AtomicU64::new(0),
|
||||||
route_pending_tp: DashMap::new(),
|
route_pending_tp: DashMap::new(),
|
||||||
@@ -264,6 +361,7 @@ impl MetricsCollector {
|
|||||||
self.ip_bytes_out.remove(ip);
|
self.ip_bytes_out.remove(ip);
|
||||||
self.ip_pending_tp.remove(ip);
|
self.ip_pending_tp.remove(ip);
|
||||||
self.ip_throughput.remove(ip);
|
self.ip_throughput.remove(ip);
|
||||||
|
self.ip_domain_requests.remove(ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,6 +463,37 @@ impl MetricsCollector {
|
|||||||
self.pending_http_requests.fetch_add(1, Ordering::Relaxed);
|
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 ──
|
// ── UDP session recording methods ──
|
||||||
|
|
||||||
/// Record a new UDP session opened.
|
/// Record a new UDP session opened.
|
||||||
@@ -388,6 +517,63 @@ impl MetricsCollector {
|
|||||||
self.total_datagrams_out.fetch_add(1, Ordering::Relaxed);
|
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 ──
|
// ── Per-backend recording methods ──
|
||||||
|
|
||||||
/// Record a successful backend connection with its connect duration.
|
/// Record a successful backend connection with its connect duration.
|
||||||
@@ -601,6 +787,25 @@ impl MetricsCollector {
|
|||||||
self.ip_pending_tp.retain(|k, _| self.ip_connections.contains_key(k));
|
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_throughput.retain(|k, _| self.ip_connections.contains_key(k));
|
||||||
self.ip_total_connections.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).
|
||||||
|
// These accumulate when backend_connect_error/backend_handshake_error
|
||||||
|
// create entries but backend_connection_opened is never called.
|
||||||
|
let known_backends: HashSet<String> = self.backend_active.iter()
|
||||||
|
.map(|e| e.key().clone())
|
||||||
|
.chain(self.backend_total.iter().map(|e| e.key().clone()))
|
||||||
|
.collect();
|
||||||
|
self.backend_connect_errors.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_handshake_errors.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_request_errors.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_connect_time_us.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_connect_count.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_pool_hits.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_pool_misses.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_h2_failures.retain(|k, _| known_backends.contains(k));
|
||||||
|
self.backend_protocol.retain(|k, _| known_backends.contains(k));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove per-route metrics for route IDs that are no longer active.
|
/// Remove per-route metrics for route IDs that are no longer active.
|
||||||
@@ -690,7 +895,7 @@ impl MetricsCollector {
|
|||||||
|
|
||||||
// Collect per-IP metrics — only IPs with active connections or total > 0,
|
// Collect per-IP metrics — only IPs with active connections or total > 0,
|
||||||
// capped at top MAX_IPS_IN_SNAPSHOT sorted by active count
|
// 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() {
|
for entry in self.ip_total_connections.iter() {
|
||||||
let ip = entry.key().clone();
|
let ip = entry.key().clone();
|
||||||
let total = entry.value().load(Ordering::Relaxed);
|
let total = entry.value().load(Ordering::Relaxed);
|
||||||
@@ -710,14 +915,23 @@ impl MetricsCollector {
|
|||||||
.get(&ip)
|
.get(&ip)
|
||||||
.and_then(|entry| entry.value().lock().ok().map(|t| t.instant()))
|
.and_then(|entry| entry.value().lock().ok().map(|t| t.instant()))
|
||||||
.unwrap_or((0, 0));
|
.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
|
// Sort by active connections descending, then cap
|
||||||
ip_entries.sort_by(|a, b| b.1.cmp(&a.1));
|
ip_entries.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
ip_entries.truncate(MAX_IPS_IN_SNAPSHOT);
|
ip_entries.truncate(MAX_IPS_IN_SNAPSHOT);
|
||||||
|
|
||||||
let mut ips = std::collections::HashMap::new();
|
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 {
|
ips.insert(ip, IpMetrics {
|
||||||
active_connections: active,
|
active_connections: active,
|
||||||
total_connections: total,
|
total_connections: total,
|
||||||
@@ -725,6 +939,7 @@ impl MetricsCollector {
|
|||||||
bytes_out,
|
bytes_out,
|
||||||
throughput_in_bytes_per_sec: tp_in,
|
throughput_in_bytes_per_sec: tp_in,
|
||||||
throughput_out_bytes_per_sec: tp_out,
|
throughput_out_bytes_per_sec: tp_out,
|
||||||
|
domain_requests,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +1039,31 @@ impl MetricsCollector {
|
|||||||
total_udp_sessions: self.total_udp_sessions.load(Ordering::Relaxed),
|
total_udp_sessions: self.total_udp_sessions.load(Ordering::Relaxed),
|
||||||
total_datagrams_in: self.total_datagrams_in.load(Ordering::Relaxed),
|
total_datagrams_in: self.total_datagrams_in.load(Ordering::Relaxed),
|
||||||
total_datagrams_out: self.total_datagrams_out.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),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "rustproxy-nftables"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
description = "NFTables kernel-level forwarding for RustProxy"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
rustproxy-config = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
libc = { workspace = true }
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//! # rustproxy-nftables
|
|
||||||
//!
|
|
||||||
//! NFTables kernel-level forwarding for RustProxy.
|
|
||||||
//! Generates and manages nft CLI rules for DNAT/SNAT.
|
|
||||||
|
|
||||||
pub mod nft_manager;
|
|
||||||
pub mod rule_builder;
|
|
||||||
|
|
||||||
pub use nft_manager::*;
|
|
||||||
pub use rule_builder::*;
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum NftError {
|
|
||||||
#[error("nft command failed: {0}")]
|
|
||||||
CommandFailed(String),
|
|
||||||
#[error("IO error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("Not running as root")]
|
|
||||||
NotRoot,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manager for nftables rules.
|
|
||||||
///
|
|
||||||
/// Executes `nft` CLI commands to manage kernel-level packet forwarding.
|
|
||||||
/// Requires root privileges; operations are skipped gracefully if not root.
|
|
||||||
pub struct NftManager {
|
|
||||||
table_name: String,
|
|
||||||
/// Active rules indexed by route ID
|
|
||||||
active_rules: HashMap<String, Vec<String>>,
|
|
||||||
/// Whether the table has been initialized
|
|
||||||
table_initialized: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NftManager {
|
|
||||||
pub fn new(table_name: Option<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
table_name: table_name.unwrap_or_else(|| "rustproxy".to_string()),
|
|
||||||
active_rules: HashMap::new(),
|
|
||||||
table_initialized: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if we are running as root.
|
|
||||||
fn is_root() -> bool {
|
|
||||||
unsafe { libc::geteuid() == 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a single nft command via the CLI.
|
|
||||||
async fn exec_nft(command: &str) -> Result<String, NftError> {
|
|
||||||
// The command starts with "nft ", strip it to get the args
|
|
||||||
let args = if command.starts_with("nft ") {
|
|
||||||
&command[4..]
|
|
||||||
} else {
|
|
||||||
command
|
|
||||||
};
|
|
||||||
|
|
||||||
let output = tokio::process::Command::new("nft")
|
|
||||||
.args(args.split_whitespace())
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map_err(NftError::Io)?;
|
|
||||||
|
|
||||||
if output.status.success() {
|
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
||||||
} else {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
Err(NftError::CommandFailed(format!(
|
|
||||||
"Command '{}' failed: {}",
|
|
||||||
command, stderr
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure the nftables table and chains are set up.
|
|
||||||
async fn ensure_table(&mut self) -> Result<(), NftError> {
|
|
||||||
if self.table_initialized {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let setup_commands = crate::rule_builder::build_table_setup(&self.table_name);
|
|
||||||
for cmd in &setup_commands {
|
|
||||||
Self::exec_nft(cmd).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.table_initialized = true;
|
|
||||||
info!("NFTables table '{}' initialized", self.table_name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply rules for a route.
|
|
||||||
///
|
|
||||||
/// Executes the nft commands via the CLI. If not running as root,
|
|
||||||
/// the rules are stored locally but not applied to the kernel.
|
|
||||||
pub async fn apply_rules(&mut self, route_id: &str, rules: Vec<String>) -> Result<(), NftError> {
|
|
||||||
if !Self::is_root() {
|
|
||||||
warn!("Not running as root, nftables rules will not be applied to kernel");
|
|
||||||
self.active_rules.insert(route_id.to_string(), rules);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.ensure_table().await?;
|
|
||||||
|
|
||||||
for cmd in &rules {
|
|
||||||
Self::exec_nft(cmd).await?;
|
|
||||||
debug!("Applied nft rule: {}", cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Applied {} nftables rules for route '{}'", rules.len(), route_id);
|
|
||||||
self.active_rules.insert(route_id.to_string(), rules);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove rules for a route.
|
|
||||||
///
|
|
||||||
/// Currently removes the route from tracking. To fully remove specific
|
|
||||||
/// rules would require handle-based tracking; for now, cleanup() removes
|
|
||||||
/// the entire table.
|
|
||||||
pub async fn remove_rules(&mut self, route_id: &str) -> Result<(), NftError> {
|
|
||||||
if let Some(rules) = self.active_rules.remove(route_id) {
|
|
||||||
info!("Removed {} tracked nft rules for route '{}'", rules.len(), route_id);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clean up all managed rules by deleting the entire nftables table.
|
|
||||||
pub async fn cleanup(&mut self) -> Result<(), NftError> {
|
|
||||||
if !Self::is_root() {
|
|
||||||
warn!("Not running as root, skipping nftables cleanup");
|
|
||||||
self.active_rules.clear();
|
|
||||||
self.table_initialized = false;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.table_initialized {
|
|
||||||
let cleanup_commands = crate::rule_builder::build_table_cleanup(&self.table_name);
|
|
||||||
for cmd in &cleanup_commands {
|
|
||||||
match Self::exec_nft(cmd).await {
|
|
||||||
Ok(_) => debug!("Cleanup: {}", cmd),
|
|
||||||
Err(e) => warn!("Cleanup command failed (may be ok): {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("NFTables table '{}' cleaned up", self.table_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.active_rules.clear();
|
|
||||||
self.table_initialized = false;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the table name.
|
|
||||||
pub fn table_name(&self) -> &str {
|
|
||||||
&self.table_name
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the table has been initialized in the kernel.
|
|
||||||
pub fn is_initialized(&self) -> bool {
|
|
||||||
self.table_initialized
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of active route rule sets.
|
|
||||||
pub fn active_route_count(&self) -> usize {
|
|
||||||
self.active_rules.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the status of all active rules.
|
|
||||||
pub fn status(&self) -> HashMap<String, serde_json::Value> {
|
|
||||||
let mut status = HashMap::new();
|
|
||||||
for (route_id, rules) in &self.active_rules {
|
|
||||||
status.insert(
|
|
||||||
route_id.clone(),
|
|
||||||
serde_json::json!({
|
|
||||||
"ruleCount": rules.len(),
|
|
||||||
"rules": rules,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_new_default_table_name() {
|
|
||||||
let mgr = NftManager::new(None);
|
|
||||||
assert_eq!(mgr.table_name(), "rustproxy");
|
|
||||||
assert!(!mgr.is_initialized());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_new_custom_table_name() {
|
|
||||||
let mgr = NftManager::new(Some("custom".to_string()));
|
|
||||||
assert_eq!(mgr.table_name(), "custom");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_apply_rules_non_root() {
|
|
||||||
let mut mgr = NftManager::new(None);
|
|
||||||
// When not root, rules are stored but not applied to kernel
|
|
||||||
let rules = vec!["nft add rule ip rustproxy prerouting tcp dport 443 dnat to 10.0.0.1:8443".to_string()];
|
|
||||||
mgr.apply_rules("route-1", rules).await.unwrap();
|
|
||||||
assert_eq!(mgr.active_route_count(), 1);
|
|
||||||
|
|
||||||
let status = mgr.status();
|
|
||||||
assert!(status.contains_key("route-1"));
|
|
||||||
assert_eq!(status["route-1"]["ruleCount"], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_remove_rules() {
|
|
||||||
let mut mgr = NftManager::new(None);
|
|
||||||
let rules = vec!["nft add rule test".to_string()];
|
|
||||||
mgr.apply_rules("route-1", rules).await.unwrap();
|
|
||||||
assert_eq!(mgr.active_route_count(), 1);
|
|
||||||
|
|
||||||
mgr.remove_rules("route-1").await.unwrap();
|
|
||||||
assert_eq!(mgr.active_route_count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_cleanup_non_root() {
|
|
||||||
let mut mgr = NftManager::new(None);
|
|
||||||
let rules = vec!["nft add rule test".to_string()];
|
|
||||||
mgr.apply_rules("route-1", rules).await.unwrap();
|
|
||||||
mgr.apply_rules("route-2", vec!["nft add rule test2".to_string()]).await.unwrap();
|
|
||||||
|
|
||||||
mgr.cleanup().await.unwrap();
|
|
||||||
assert_eq!(mgr.active_route_count(), 0);
|
|
||||||
assert!(!mgr.is_initialized());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_status_multiple_routes() {
|
|
||||||
let mut mgr = NftManager::new(None);
|
|
||||||
mgr.apply_rules("web", vec!["rule1".to_string(), "rule2".to_string()]).await.unwrap();
|
|
||||||
mgr.apply_rules("api", vec!["rule3".to_string()]).await.unwrap();
|
|
||||||
|
|
||||||
let status = mgr.status();
|
|
||||||
assert_eq!(status.len(), 2);
|
|
||||||
assert_eq!(status["web"]["ruleCount"], 2);
|
|
||||||
assert_eq!(status["api"]["ruleCount"], 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
use rustproxy_config::{NfTablesOptions, NfTablesProtocol};
|
|
||||||
|
|
||||||
/// Build nftables DNAT rule for port forwarding.
|
|
||||||
pub fn build_dnat_rule(
|
|
||||||
table_name: &str,
|
|
||||||
chain_name: &str,
|
|
||||||
source_port: u16,
|
|
||||||
target_host: &str,
|
|
||||||
target_port: u16,
|
|
||||||
options: &NfTablesOptions,
|
|
||||||
) -> Vec<String> {
|
|
||||||
let protocols: Vec<&str> = match options.protocol.as_ref().unwrap_or(&NfTablesProtocol::Tcp) {
|
|
||||||
NfTablesProtocol::Tcp => vec!["tcp"],
|
|
||||||
NfTablesProtocol::Udp => vec!["udp"],
|
|
||||||
NfTablesProtocol::All => vec!["tcp", "udp"],
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut rules = Vec::new();
|
|
||||||
|
|
||||||
for protocol in &protocols {
|
|
||||||
// DNAT rule
|
|
||||||
rules.push(format!(
|
|
||||||
"nft add rule ip {} {} {} dport {} dnat to {}:{}",
|
|
||||||
table_name, chain_name, protocol, source_port, target_host, target_port,
|
|
||||||
));
|
|
||||||
|
|
||||||
// SNAT rule if preserving source IP is not enabled
|
|
||||||
if !options.preserve_source_ip.unwrap_or(false) {
|
|
||||||
rules.push(format!(
|
|
||||||
"nft add rule ip {} postrouting {} dport {} masquerade",
|
|
||||||
table_name, protocol, target_port,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
if let Some(max_rate) = &options.max_rate {
|
|
||||||
rules.push(format!(
|
|
||||||
"nft add rule ip {} {} {} dport {} limit rate {} accept",
|
|
||||||
table_name, chain_name, protocol, source_port, max_rate,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rules
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the initial table and chain setup commands.
|
|
||||||
pub fn build_table_setup(table_name: &str) -> Vec<String> {
|
|
||||||
vec![
|
|
||||||
format!("nft add table ip {}", table_name),
|
|
||||||
format!("nft add chain ip {} prerouting {{ type nat hook prerouting priority 0 \\; }}", table_name),
|
|
||||||
format!("nft add chain ip {} postrouting {{ type nat hook postrouting priority 100 \\; }}", table_name),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build cleanup commands to remove the table.
|
|
||||||
pub fn build_table_cleanup(table_name: &str) -> Vec<String> {
|
|
||||||
vec![format!("nft delete table ip {}", table_name)]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_options() -> NfTablesOptions {
|
|
||||||
NfTablesOptions {
|
|
||||||
preserve_source_ip: None,
|
|
||||||
protocol: None,
|
|
||||||
max_rate: None,
|
|
||||||
priority: None,
|
|
||||||
table_name: None,
|
|
||||||
use_ip_sets: None,
|
|
||||||
use_advanced_nat: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_basic_dnat_rule() {
|
|
||||||
let options = make_options();
|
|
||||||
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
|
|
||||||
assert!(rules.len() >= 1);
|
|
||||||
assert!(rules[0].contains("dnat to 10.0.0.1:8443"));
|
|
||||||
assert!(rules[0].contains("dport 443"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_preserve_source_ip() {
|
|
||||||
let mut options = make_options();
|
|
||||||
options.preserve_source_ip = Some(true);
|
|
||||||
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
|
|
||||||
// When preserving source IP, no masquerade rule
|
|
||||||
assert!(rules.iter().all(|r| !r.contains("masquerade")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_without_preserve_source_ip() {
|
|
||||||
let options = make_options();
|
|
||||||
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
|
|
||||||
assert!(rules.iter().any(|r| r.contains("masquerade")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_rate_limited_rule() {
|
|
||||||
let mut options = make_options();
|
|
||||||
options.max_rate = Some("100/second".to_string());
|
|
||||||
let rules = build_dnat_rule("rustproxy", "prerouting", 80, "10.0.0.1", 8080, &options);
|
|
||||||
assert!(rules.iter().any(|r| r.contains("limit rate 100/second")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_table_setup_commands() {
|
|
||||||
let commands = build_table_setup("rustproxy");
|
|
||||||
assert_eq!(commands.len(), 3);
|
|
||||||
assert!(commands[0].contains("add table ip rustproxy"));
|
|
||||||
assert!(commands[1].contains("prerouting"));
|
|
||||||
assert!(commands[2].contains("postrouting"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_table_cleanup() {
|
|
||||||
let commands = build_table_cleanup("rustproxy");
|
|
||||||
assert_eq!(commands.len(), 1);
|
|
||||||
assert!(commands[0].contains("delete table ip rustproxy"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_protocol_all_generates_tcp_and_udp_rules() {
|
|
||||||
let mut options = make_options();
|
|
||||||
options.protocol = Some(NfTablesProtocol::All);
|
|
||||||
let rules = build_dnat_rule("rustproxy", "prerouting", 53, "10.0.0.53", 53, &options);
|
|
||||||
// Should have TCP DNAT + masquerade + UDP DNAT + masquerade = 4 rules
|
|
||||||
assert_eq!(rules.len(), 4);
|
|
||||||
assert!(rules.iter().any(|r| r.contains("tcp dport 53 dnat")));
|
|
||||||
assert!(rules.iter().any(|r| r.contains("udp dport 53 dnat")));
|
|
||||||
assert!(rules.iter().filter(|r| r.contains("masquerade")).count() == 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_protocol_udp() {
|
|
||||||
let mut options = make_options();
|
|
||||||
options.protocol = Some(NfTablesProtocol::Udp);
|
|
||||||
let rules = build_dnat_rule("rustproxy", "prerouting", 53, "10.0.0.53", 53, &options);
|
|
||||||
assert!(rules.iter().all(|r| !r.contains("tcp")));
|
|
||||||
assert!(rules.iter().any(|r| r.contains("udp dport 53 dnat")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,7 +10,7 @@ pub mod forwarder;
|
|||||||
pub mod proxy_protocol;
|
pub mod proxy_protocol;
|
||||||
pub mod tls_handler;
|
pub mod tls_handler;
|
||||||
pub mod connection_tracker;
|
pub mod connection_tracker;
|
||||||
pub mod socket_relay;
|
pub mod connection_registry;
|
||||||
pub mod socket_opts;
|
pub mod socket_opts;
|
||||||
pub mod udp_session;
|
pub mod udp_session;
|
||||||
pub mod udp_listener;
|
pub mod udp_listener;
|
||||||
@@ -22,7 +22,7 @@ pub use forwarder::*;
|
|||||||
pub use proxy_protocol::*;
|
pub use proxy_protocol::*;
|
||||||
pub use tls_handler::*;
|
pub use tls_handler::*;
|
||||||
pub use connection_tracker::*;
|
pub use connection_tracker::*;
|
||||||
pub use socket_relay::*;
|
pub use connection_registry::*;
|
||||||
pub use socket_opts::*;
|
pub use socket_opts::*;
|
||||||
pub use udp_session::*;
|
pub use udp_session::*;
|
||||||
pub use udp_listener::*;
|
pub use udp_listener::*;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use rustproxy_routing::{MatchContext, RouteManager};
|
|||||||
use rustproxy_http::h3_service::H3ProxyService;
|
use rustproxy_http::h3_service::H3ProxyService;
|
||||||
|
|
||||||
use crate::connection_tracker::ConnectionTracker;
|
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.
|
/// Create a QUIC server endpoint on the given port with the provided TLS config.
|
||||||
///
|
///
|
||||||
@@ -77,6 +78,13 @@ struct RelaySession {
|
|||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for RelaySession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.cancel.cancel();
|
||||||
|
self.return_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a QUIC endpoint with a PROXY protocol v2 relay layer.
|
/// Create a QUIC endpoint with a PROXY protocol v2 relay layer.
|
||||||
///
|
///
|
||||||
/// Instead of giving the external socket to quinn, we:
|
/// Instead of giving the external socket to quinn, we:
|
||||||
@@ -343,6 +351,8 @@ pub async fn quic_accept_loop(
|
|||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
h3_service: Option<Arc<H3ProxyService>>,
|
h3_service: Option<Arc<H3ProxyService>>,
|
||||||
real_client_map: Option<Arc<DashMap<SocketAddr, SocketAddr>>>,
|
real_client_map: Option<Arc<DashMap<SocketAddr, SocketAddr>>>,
|
||||||
|
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
||||||
|
connection_registry: Arc<ConnectionRegistry>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
let incoming = tokio::select! {
|
let incoming = tokio::select! {
|
||||||
@@ -399,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);
|
conn_tracker.connection_opened(&ip);
|
||||||
let route_id = route.name.clone().or(route.id.clone());
|
let route_id = route.name.clone().or(route.id.clone());
|
||||||
metrics.connection_opened(route_id.as_deref(), Some(&ip_str));
|
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 metrics = Arc::clone(&metrics);
|
||||||
let conn_tracker = Arc::clone(&conn_tracker);
|
let conn_tracker = Arc::clone(&conn_tracker);
|
||||||
let cancel = cancel.child_token();
|
|
||||||
let h3_svc = h3_service.clone();
|
let h3_svc = h3_service.clone();
|
||||||
let real_client_addr = if real_addr != remote_addr { Some(real_addr) } else { None };
|
let real_client_addr = if real_addr != remote_addr { Some(real_addr) } else { None };
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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
|
// RAII guard: ensures metrics/tracker cleanup even on panic
|
||||||
struct QuicConnGuard {
|
struct QuicConnGuard {
|
||||||
tracker: Arc<ConnectionTracker>,
|
tracker: Arc<ConnectionTracker>,
|
||||||
@@ -432,7 +473,7 @@ pub async fn quic_accept_loop(
|
|||||||
route_id,
|
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),
|
Ok(()) => debug!("QUIC connection from {} completed", real_addr),
|
||||||
Err(e) => debug!("QUIC connection from {} error: {}", real_addr, e),
|
Err(e) => debug!("QUIC connection from {} error: {}", real_addr, e),
|
||||||
}
|
}
|
||||||
@@ -634,7 +675,7 @@ async fn forward_quic_stream_to_tcp(
|
|||||||
let la_watch = Arc::clone(&last_activity);
|
let la_watch = Arc::clone(&last_activity);
|
||||||
let c2b_abort = c2b.abort_handle();
|
let c2b_abort = c2b.abort_handle();
|
||||||
let b2c_abort = b2c.abort_handle();
|
let b2c_abort = b2c.abort_handle();
|
||||||
tokio::spawn(async move {
|
let watchdog = tokio::spawn(async move {
|
||||||
let check_interval = std::time::Duration::from_secs(5);
|
let check_interval = std::time::Duration::from_secs(5);
|
||||||
let mut last_seen = 0u64;
|
let mut last_seen = 0u64;
|
||||||
loop {
|
loop {
|
||||||
@@ -665,6 +706,7 @@ async fn forward_quic_stream_to_tcp(
|
|||||||
|
|
||||||
let bytes_in = c2b.await.unwrap_or(0);
|
let bytes_in = c2b.await.unwrap_or(0);
|
||||||
let bytes_out = b2c.await.unwrap_or(0);
|
let bytes_out = b2c.await.unwrap_or(0);
|
||||||
|
watchdog.abort();
|
||||||
|
|
||||||
Ok((bytes_in, bytes_out))
|
Ok((bytes_in, bytes_out))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,4 @@
|
|||||||
//! Socket handler relay for connecting client connections to a TypeScript handler
|
//! Socket handler relay module.
|
||||||
//! via a Unix domain socket.
|
|
||||||
//!
|
//!
|
||||||
//! Protocol: Send a JSON metadata line terminated by `\n`, then bidirectional relay.
|
//! Note: The actual relay logic lives in `tcp_listener::relay_to_socket_handler()`
|
||||||
|
//! which has proper timeouts, cancellation, and metrics integration.
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio::io::{AsyncWriteExt, AsyncReadExt};
|
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use serde::Serialize;
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct RelayMetadata {
|
|
||||||
connection_id: u64,
|
|
||||||
remote_ip: String,
|
|
||||||
remote_port: u16,
|
|
||||||
local_port: u16,
|
|
||||||
sni: Option<String>,
|
|
||||||
route_name: String,
|
|
||||||
initial_data_base64: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relay a client connection to a TypeScript handler via Unix domain socket.
|
|
||||||
///
|
|
||||||
/// Protocol: Send a JSON metadata line terminated by `\n`, then bidirectional relay.
|
|
||||||
pub async fn relay_to_handler(
|
|
||||||
client: TcpStream,
|
|
||||||
relay_socket_path: &str,
|
|
||||||
connection_id: u64,
|
|
||||||
remote_ip: String,
|
|
||||||
remote_port: u16,
|
|
||||||
local_port: u16,
|
|
||||||
sni: Option<String>,
|
|
||||||
route_name: String,
|
|
||||||
initial_data: Option<&[u8]>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
debug!(
|
|
||||||
"Relaying connection {} to handler socket {}",
|
|
||||||
connection_id, relay_socket_path
|
|
||||||
);
|
|
||||||
|
|
||||||
// Connect to TypeScript handler Unix socket
|
|
||||||
let mut handler = UnixStream::connect(relay_socket_path).await?;
|
|
||||||
|
|
||||||
// Build and send metadata header
|
|
||||||
let initial_data_base64 = initial_data.map(base64_encode);
|
|
||||||
|
|
||||||
let metadata = RelayMetadata {
|
|
||||||
connection_id,
|
|
||||||
remote_ip,
|
|
||||||
remote_port,
|
|
||||||
local_port,
|
|
||||||
sni,
|
|
||||||
route_name,
|
|
||||||
initial_data_base64,
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata_json = serde_json::to_string(&metadata)
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
|
||||||
|
|
||||||
handler.write_all(metadata_json.as_bytes()).await?;
|
|
||||||
handler.write_all(b"\n").await?;
|
|
||||||
|
|
||||||
// Bidirectional relay between client and handler
|
|
||||||
let (mut client_read, mut client_write) = client.into_split();
|
|
||||||
let (mut handler_read, mut handler_write) = handler.into_split();
|
|
||||||
|
|
||||||
let c2h = tokio::spawn(async move {
|
|
||||||
let mut buf = vec![0u8; 65536];
|
|
||||||
loop {
|
|
||||||
let n = match client_read.read(&mut buf).await {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(n) => n,
|
|
||||||
};
|
|
||||||
if handler_write.write_all(&buf[..n]).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = handler_write.shutdown().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let h2c = tokio::spawn(async move {
|
|
||||||
let mut buf = vec![0u8; 65536];
|
|
||||||
loop {
|
|
||||||
let n = match handler_read.read(&mut buf).await {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(n) => n,
|
|
||||||
};
|
|
||||||
if client_write.write_all(&buf[..n]).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = client_write.shutdown().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = tokio::join!(c2h, h2c);
|
|
||||||
|
|
||||||
debug!("Relay connection {} completed", connection_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple base64 encoding without external dependency.
|
|
||||||
fn base64_encode(data: &[u8]) -> String {
|
|
||||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
let mut result = String::new();
|
|
||||||
for chunk in data.chunks(3) {
|
|
||||||
let b0 = chunk[0] as u32;
|
|
||||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
|
||||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
|
||||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
|
||||||
result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
|
||||||
result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
|
||||||
if chunk.len() > 1 {
|
|
||||||
result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
|
||||||
} else {
|
|
||||||
result.push('=');
|
|
||||||
}
|
|
||||||
if chunk.len() > 2 {
|
|
||||||
result.push(CHARS[(n & 0x3F) as usize] as char);
|
|
||||||
} else {
|
|
||||||
result.push('=');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::sni_parser;
|
|||||||
use crate::forwarder;
|
use crate::forwarder;
|
||||||
use crate::tls_handler;
|
use crate::tls_handler;
|
||||||
use crate::connection_tracker::ConnectionTracker;
|
use crate::connection_tracker::ConnectionTracker;
|
||||||
|
use crate::connection_registry::{ConnectionEntry, ConnectionRegistry};
|
||||||
use crate::socket_opts;
|
use crate::socket_opts;
|
||||||
|
|
||||||
/// RAII guard that decrements the active connection metric on drop.
|
/// 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.
|
/// RAII guard that calls ConnectionTracker::connection_closed on drop.
|
||||||
/// Ensures per-IP tracking is cleaned up on ALL exit paths — normal, error, or panic.
|
/// Ensures per-IP tracking is cleaned up on ALL exit paths — normal, error, or panic.
|
||||||
struct ConnectionTrackerGuard {
|
struct ConnectionTrackerGuard {
|
||||||
@@ -166,6 +194,8 @@ pub struct TcpListenerManager {
|
|||||||
/// Per-route cancellation tokens (child of cancel_token).
|
/// Per-route cancellation tokens (child of cancel_token).
|
||||||
/// When a route is removed, its token is cancelled, terminating all connections on that route.
|
/// When a route is removed, its token is cancelled, terminating all connections on that route.
|
||||||
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
||||||
|
/// Shared connection registry for selective recycling on config changes.
|
||||||
|
connection_registry: Arc<ConnectionRegistry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TcpListenerManager {
|
impl TcpListenerManager {
|
||||||
@@ -182,6 +212,7 @@ impl TcpListenerManager {
|
|||||||
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
||||||
http_proxy_svc.set_connection_timeouts(
|
http_proxy_svc.set_connection_timeouts(
|
||||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||||
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||||
);
|
);
|
||||||
@@ -204,6 +235,7 @@ impl TcpListenerManager {
|
|||||||
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
|
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
|
||||||
conn_semaphore: Arc::new(tokio::sync::Semaphore::new(max_conns)),
|
conn_semaphore: Arc::new(tokio::sync::Semaphore::new(max_conns)),
|
||||||
route_cancels: Arc::new(DashMap::new()),
|
route_cancels: Arc::new(DashMap::new()),
|
||||||
|
connection_registry: Arc::new(ConnectionRegistry::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +252,7 @@ impl TcpListenerManager {
|
|||||||
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
||||||
http_proxy_svc.set_connection_timeouts(
|
http_proxy_svc.set_connection_timeouts(
|
||||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||||
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
std::time::Duration::from_millis(conn_config.socket_timeout_ms),
|
||||||
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
std::time::Duration::from_millis(conn_config.max_connection_lifetime_ms),
|
||||||
);
|
);
|
||||||
@@ -242,6 +275,7 @@ impl TcpListenerManager {
|
|||||||
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
|
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
|
||||||
conn_semaphore: Arc::new(tokio::sync::Semaphore::new(max_conns)),
|
conn_semaphore: Arc::new(tokio::sync::Semaphore::new(max_conns)),
|
||||||
route_cancels: Arc::new(DashMap::new()),
|
route_cancels: Arc::new(DashMap::new()),
|
||||||
|
connection_registry: Arc::new(ConnectionRegistry::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +297,7 @@ impl TcpListenerManager {
|
|||||||
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
http_proxy_svc.set_backend_tls_config_alpn(tls_handler::shared_backend_tls_config_alpn());
|
||||||
http_proxy_svc.set_connection_timeouts(
|
http_proxy_svc.set_connection_timeouts(
|
||||||
std::time::Duration::from_millis(config.socket_timeout_ms),
|
std::time::Duration::from_millis(config.socket_timeout_ms),
|
||||||
|
std::time::Duration::from_millis(config.max_connection_lifetime_ms),
|
||||||
std::time::Duration::from_millis(config.socket_timeout_ms),
|
std::time::Duration::from_millis(config.socket_timeout_ms),
|
||||||
std::time::Duration::from_millis(config.max_connection_lifetime_ms),
|
std::time::Duration::from_millis(config.max_connection_lifetime_ms),
|
||||||
);
|
);
|
||||||
@@ -325,12 +360,13 @@ impl TcpListenerManager {
|
|||||||
let relay = Arc::clone(&self.socket_handler_relay);
|
let relay = Arc::clone(&self.socket_handler_relay);
|
||||||
let semaphore = Arc::clone(&self.conn_semaphore);
|
let semaphore = Arc::clone(&self.conn_semaphore);
|
||||||
let route_cancels = Arc::clone(&self.route_cancels);
|
let route_cancels = Arc::clone(&self.route_cancels);
|
||||||
|
let connection_registry = Arc::clone(&self.connection_registry);
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
Self::accept_loop(
|
Self::accept_loop(
|
||||||
listener, port, route_manager_swap, metrics, tls_configs,
|
listener, port, route_manager_swap, metrics, tls_configs,
|
||||||
shared_tls_acceptor, http_proxy, conn_config, conn_tracker, cancel, relay,
|
shared_tls_acceptor, http_proxy, conn_config, conn_tracker, cancel, relay,
|
||||||
semaphore, route_cancels,
|
semaphore, route_cancels, connection_registry,
|
||||||
).await;
|
).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,6 +479,16 @@ impl TcpListenerManager {
|
|||||||
&self.metrics
|
&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.
|
/// Accept loop for a single port.
|
||||||
async fn accept_loop(
|
async fn accept_loop(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
@@ -458,6 +504,7 @@ impl TcpListenerManager {
|
|||||||
socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>,
|
socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>,
|
||||||
conn_semaphore: Arc<tokio::sync::Semaphore>,
|
conn_semaphore: Arc<tokio::sync::Semaphore>,
|
||||||
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
||||||
|
connection_registry: Arc<ConnectionRegistry>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -511,6 +558,7 @@ impl TcpListenerManager {
|
|||||||
let cn = cancel.clone();
|
let cn = cancel.clone();
|
||||||
let sr = Arc::clone(&socket_handler_relay);
|
let sr = Arc::clone(&socket_handler_relay);
|
||||||
let rc = Arc::clone(&route_cancels);
|
let rc = Arc::clone(&route_cancels);
|
||||||
|
let cr = Arc::clone(&connection_registry);
|
||||||
debug!("Accepted connection from {} on port {}", peer_addr, port);
|
debug!("Accepted connection from {} on port {}", peer_addr, port);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -519,7 +567,7 @@ impl TcpListenerManager {
|
|||||||
// RAII guard ensures connection_closed is called on all paths
|
// RAII guard ensures connection_closed is called on all paths
|
||||||
let _ct_guard = ConnectionTrackerGuard::new(ct, ip);
|
let _ct_guard = ConnectionTrackerGuard::new(ct, ip);
|
||||||
let result = Self::handle_connection(
|
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;
|
).await;
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
warn!("Connection error from {}: {}", peer_addr, e);
|
warn!("Connection error from {}: {}", peer_addr, e);
|
||||||
@@ -550,6 +598,7 @@ impl TcpListenerManager {
|
|||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>,
|
socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>,
|
||||||
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
||||||
|
connection_registry: Arc<ConnectionRegistry>,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
@@ -669,17 +718,29 @@ impl TcpListenerManager {
|
|||||||
let route_id = quick_match.route.id.as_deref();
|
let route_id = quick_match.route.id.as_deref();
|
||||||
|
|
||||||
// Resolve per-route cancel token (child of global cancel)
|
// 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())
|
Some(id) => route_cancels.entry(id.to_string())
|
||||||
.or_insert_with(|| cancel.child_token())
|
.or_insert_with(|| cancel.child_token())
|
||||||
.clone(),
|
.clone(),
|
||||||
None => cancel.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 let Some(ref security) = quick_match.route.security {
|
||||||
if !rustproxy_http::request_filter::RequestFilter::check_ip_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);
|
warn!("Connection from {} blocked by route security", peer_addr);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -849,18 +910,31 @@ impl TcpListenerManager {
|
|||||||
// Resolve per-route cancel token (child of global cancel).
|
// Resolve per-route cancel token (child of global cancel).
|
||||||
// When this route is removed via updateRoutes, the token is cancelled,
|
// When this route is removed via updateRoutes, the token is cancelled,
|
||||||
// terminating all connections on this route.
|
// 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())
|
Some(id) => route_cancels.entry(id.to_string())
|
||||||
.or_insert_with(|| cancel.child_token())
|
.or_insert_with(|| cancel.child_token())
|
||||||
.clone(),
|
.clone(),
|
||||||
None => cancel,
|
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 let Some(ref security) = route_match.route.security {
|
||||||
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
|
if !rustproxy_http::request_filter::RequestFilter::check_ip_security(
|
||||||
security,
|
security,
|
||||||
&peer_addr.ip(),
|
&peer_addr.ip(),
|
||||||
|
domain.as_deref(),
|
||||||
) {
|
) {
|
||||||
warn!("Connection from {} blocked by route security", peer_addr);
|
warn!("Connection from {} blocked by route security", peer_addr);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -869,6 +943,9 @@ impl TcpListenerManager {
|
|||||||
|
|
||||||
// Track connection in metrics — guard ensures connection_closed on all exit paths
|
// Track connection in metrics — guard ensures connection_closed on all exit paths
|
||||||
metrics.connection_opened(route_id, Some(&ip_str));
|
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));
|
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
|
// Check if this is a socket-handler route that should be relayed to TypeScript
|
||||||
@@ -978,6 +1055,9 @@ impl TcpListenerManager {
|
|||||||
peer_addr, target_host, target_port, domain
|
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];
|
let mut actual_buf = vec![0u8; n];
|
||||||
stream.read_exact(&mut actual_buf).await?;
|
stream.read_exact(&mut actual_buf).await?;
|
||||||
|
|
||||||
@@ -1044,6 +1124,8 @@ impl TcpListenerManager {
|
|||||||
"TLS Terminate + TCP: {} -> {}:{} (domain: {:?})",
|
"TLS Terminate + TCP: {} -> {}:{} (domain: {:?})",
|
||||||
peer_addr, target_host, target_port, 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
|
// Raw TCP forwarding of decrypted stream
|
||||||
let backend = match tokio::time::timeout(
|
let backend = match tokio::time::timeout(
|
||||||
connect_timeout,
|
connect_timeout,
|
||||||
@@ -1130,6 +1212,8 @@ impl TcpListenerManager {
|
|||||||
"TLS Terminate+Reencrypt + TCP: {} -> {}:{}",
|
"TLS Terminate+Reencrypt + TCP: {} -> {}:{}",
|
||||||
peer_addr, target_host, target_port
|
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(
|
Self::handle_tls_reencrypt_tunnel(
|
||||||
buf_stream, &target_host, target_port,
|
buf_stream, &target_host, target_port,
|
||||||
peer_addr, Arc::clone(&metrics), route_id,
|
peer_addr, Arc::clone(&metrics), route_id,
|
||||||
@@ -1146,6 +1230,8 @@ impl TcpListenerManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
// Plain TCP forwarding (non-HTTP)
|
// 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(
|
let mut backend = match tokio::time::timeout(
|
||||||
connect_timeout,
|
connect_timeout,
|
||||||
tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)),
|
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 rustproxy_http::h3_service::H3ProxyService;
|
||||||
|
|
||||||
|
use crate::connection_registry::ConnectionRegistry;
|
||||||
|
|
||||||
use crate::connection_tracker::ConnectionTracker;
|
use crate::connection_tracker::ConnectionTracker;
|
||||||
use crate::udp_session::{SessionKey, UdpSession, UdpSessionConfig, UdpSessionTable};
|
use crate::udp_session::{SessionKey, UdpSession, UdpSessionConfig, UdpSessionTable};
|
||||||
|
|
||||||
@@ -56,6 +58,10 @@ pub struct UdpListenerManager {
|
|||||||
/// Trusted proxy IPs that may send PROXY protocol v2 headers.
|
/// 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.
|
/// When non-empty, PROXY v2 detection is enabled on both raw UDP and QUIC paths.
|
||||||
proxy_ips: Arc<Vec<IpAddr>>,
|
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 {
|
impl Drop for UdpListenerManager {
|
||||||
@@ -76,6 +82,8 @@ impl UdpListenerManager {
|
|||||||
metrics: Arc<MetricsCollector>,
|
metrics: Arc<MetricsCollector>,
|
||||||
conn_tracker: Arc<ConnectionTracker>,
|
conn_tracker: Arc<ConnectionTracker>,
|
||||||
cancel_token: CancellationToken,
|
cancel_token: CancellationToken,
|
||||||
|
route_cancels: Arc<DashMap<String, CancellationToken>>,
|
||||||
|
connection_registry: Arc<ConnectionRegistry>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
listeners: HashMap::new(),
|
listeners: HashMap::new(),
|
||||||
@@ -89,6 +97,8 @@ impl UdpListenerManager {
|
|||||||
relay_reader_cancel: None,
|
relay_reader_cancel: None,
|
||||||
h3_service: None,
|
h3_service: None,
|
||||||
proxy_ips: Arc::new(Vec::new()),
|
proxy_ips: Arc::new(Vec::new()),
|
||||||
|
route_cancels,
|
||||||
|
connection_registry,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +162,8 @@ impl UdpListenerManager {
|
|||||||
self.cancel_token.child_token(),
|
self.cancel_token.child_token(),
|
||||||
self.h3_service.clone(),
|
self.h3_service.clone(),
|
||||||
None,
|
None,
|
||||||
|
Arc::clone(&self.route_cancels),
|
||||||
|
Arc::clone(&self.connection_registry),
|
||||||
));
|
));
|
||||||
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
||||||
info!("QUIC endpoint started on port {}", port);
|
info!("QUIC endpoint started on port {}", port);
|
||||||
@@ -173,6 +185,8 @@ impl UdpListenerManager {
|
|||||||
self.cancel_token.child_token(),
|
self.cancel_token.child_token(),
|
||||||
self.h3_service.clone(),
|
self.h3_service.clone(),
|
||||||
Some(relay.real_client_map),
|
Some(relay.real_client_map),
|
||||||
|
Arc::clone(&self.route_cancels),
|
||||||
|
Arc::clone(&self.connection_registry),
|
||||||
));
|
));
|
||||||
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
||||||
info!("QUIC endpoint with PROXY relay started on port {}", port);
|
info!("QUIC endpoint with PROXY relay started on port {}", port);
|
||||||
@@ -356,6 +370,8 @@ impl UdpListenerManager {
|
|||||||
self.cancel_token.child_token(),
|
self.cancel_token.child_token(),
|
||||||
self.h3_service.clone(),
|
self.h3_service.clone(),
|
||||||
None,
|
None,
|
||||||
|
Arc::clone(&self.route_cancels),
|
||||||
|
Arc::clone(&self.connection_registry),
|
||||||
));
|
));
|
||||||
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -379,6 +395,8 @@ impl UdpListenerManager {
|
|||||||
self.cancel_token.child_token(),
|
self.cancel_token.child_token(),
|
||||||
self.h3_service.clone(),
|
self.h3_service.clone(),
|
||||||
Some(relay.real_client_map),
|
Some(relay.real_client_map),
|
||||||
|
Arc::clone(&self.route_cancels),
|
||||||
|
Arc::clone(&self.connection_registry),
|
||||||
));
|
));
|
||||||
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use regex::Regex;
|
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.
|
/// Match HTTP headers against a set of patterns.
|
||||||
///
|
///
|
||||||
@@ -24,16 +61,15 @@ pub fn headers_match(
|
|||||||
None => return false, // Required header not present
|
None => return false, // Required header not present
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if pattern is a regex (surrounded by /)
|
// Check if pattern is a regex literal (/pattern/ or /pattern/flags)
|
||||||
if pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() > 2 {
|
if pattern.starts_with('/') && pattern.len() > 2 {
|
||||||
let regex_str = &pattern[1..pattern.len() - 1];
|
match compile_regex_pattern(pattern) {
|
||||||
match Regex::new(regex_str) {
|
Some(re) => {
|
||||||
Ok(re) => {
|
|
||||||
if !re.is_match(header_value) {
|
if !re.is_match(header_value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
// Invalid regex, fall back to exact match
|
// Invalid regex, fall back to exact match
|
||||||
if header_value != pattern {
|
if header_value != pattern {
|
||||||
return false;
|
return false;
|
||||||
@@ -85,6 +121,24 @@ mod tests {
|
|||||||
assert!(headers_match(&patterns, &headers));
|
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]
|
#[test]
|
||||||
fn test_missing_header() {
|
fn test_missing_header() {
|
||||||
let patterns: HashMap<String, String> = {
|
let patterns: HashMap<String, String> = {
|
||||||
|
|||||||
@@ -281,6 +281,11 @@ impl RouteManager {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all enabled routes.
|
||||||
|
pub fn routes(&self) -> &[RouteConfig] {
|
||||||
|
&self.routes
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the total number of enabled routes.
|
/// Get the total number of enabled routes.
|
||||||
pub fn route_count(&self) -> usize {
|
pub fn route_count(&self) -> usize {
|
||||||
self.routes.len()
|
self.routes.len()
|
||||||
@@ -355,8 +360,6 @@ mod tests {
|
|||||||
load_balancing: None,
|
load_balancing: None,
|
||||||
advanced: None,
|
advanced: None,
|
||||||
options: None,
|
options: None,
|
||||||
forwarding_engine: None,
|
|
||||||
nftables: None,
|
|
||||||
send_proxy_protocol: None,
|
send_proxy_protocol: None,
|
||||||
udp: None,
|
udp: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,12 +2,24 @@ use ipnet::IpNet;
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use rustproxy_config::IpAllowEntry;
|
||||||
|
|
||||||
/// IP filter supporting CIDR ranges, wildcards, and exact matches.
|
/// IP filter supporting CIDR ranges, wildcards, and exact matches.
|
||||||
|
/// Supports domain-scoped allow entries that restrict an IP to specific domains.
|
||||||
pub struct IpFilter {
|
pub struct IpFilter {
|
||||||
|
/// Plain allow entries — IP allowed for any domain on the route
|
||||||
allow_list: Vec<IpPattern>,
|
allow_list: Vec<IpPattern>,
|
||||||
|
/// Domain-scoped allow entries — IP allowed only for matching domains
|
||||||
|
domain_scoped: Vec<DomainScopedEntry>,
|
||||||
block_list: Vec<IpPattern>,
|
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.
|
/// Represents an IP pattern for matching.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum IpPattern {
|
enum IpPattern {
|
||||||
@@ -31,10 +43,6 @@ impl IpPattern {
|
|||||||
if let Ok(addr) = IpAddr::from_str(s) {
|
if let Ok(addr) = IpAddr::from_str(s) {
|
||||||
return IpPattern::Exact(addr);
|
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
|
// Fallback: treat as exact, will never match an invalid string
|
||||||
IpPattern::Exact(IpAddr::from_str("0.0.0.0").unwrap())
|
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 {
|
impl IpFilter {
|
||||||
/// Create a new IP filter from allow and block lists.
|
/// Create a new IP filter from allow entries and a block list.
|
||||||
pub fn new(allow_list: &[String], block_list: &[String]) -> Self {
|
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 {
|
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(),
|
block_list: block_list.iter().map(|s| IpPattern::parse(s)).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an IP is allowed.
|
/// Check if an IP is allowed, considering domain-scoped entries.
|
||||||
/// If allow_list is non-empty, IP must match at least one entry.
|
/// If `domain` is Some, domain-scoped entries are evaluated against it.
|
||||||
/// If block_list is non-empty, IP must NOT match any entry.
|
/// If `domain` is None, only plain allow entries are considered.
|
||||||
pub fn is_allowed(&self, ip: &IpAddr) -> bool {
|
pub fn is_allowed_for_domain(&self, ip: &IpAddr, domain: Option<&str>) -> bool {
|
||||||
// Check block list first
|
// Check block list first
|
||||||
if !self.block_list.is_empty() {
|
if !self.block_list.is_empty() {
|
||||||
for pattern in &self.block_list {
|
for pattern in &self.block_list {
|
||||||
@@ -70,14 +115,36 @@ impl IpFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If allow list is non-empty, must match at least one
|
// If there are any allow entries (plain or domain-scoped), IP must match
|
||||||
if !self.allow_list.is_empty() {
|
let has_any_allow = !self.allow_list.is_empty() || !self.domain_scoped.is_empty();
|
||||||
return self.allow_list.iter().any(|p| p.matches(ip));
|
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
|
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)
|
/// Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x -> x.x.x.x)
|
||||||
pub fn normalize_ip(ip: &IpAddr) -> IpAddr {
|
pub fn normalize_ip(ip: &IpAddr) -> IpAddr {
|
||||||
match ip {
|
match ip {
|
||||||
@@ -97,19 +164,28 @@ impl IpFilter {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_empty_lists_allow_all() {
|
fn test_empty_lists_allow_all() {
|
||||||
let filter = IpFilter::new(&[], &[]);
|
let filter = IpFilter::new(&[], &[]);
|
||||||
let ip: IpAddr = "192.168.1.1".parse().unwrap();
|
let ip: IpAddr = "192.168.1.1".parse().unwrap();
|
||||||
assert!(filter.is_allowed(&ip));
|
assert!(filter.is_allowed(&ip));
|
||||||
|
assert!(filter.is_allowed_for_domain(&ip, Some("example.com")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_allow_list_exact() {
|
fn test_plain_allow_list_exact() {
|
||||||
let filter = IpFilter::new(
|
let filter = IpFilter::new(&[plain("10.0.0.1")], &[]);
|
||||||
&["10.0.0.1".to_string()],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
let allowed: IpAddr = "10.0.0.1".parse().unwrap();
|
let allowed: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
let denied: IpAddr = "10.0.0.2".parse().unwrap();
|
let denied: IpAddr = "10.0.0.2".parse().unwrap();
|
||||||
assert!(filter.is_allowed(&allowed));
|
assert!(filter.is_allowed(&allowed));
|
||||||
@@ -117,11 +193,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_allow_list_cidr() {
|
fn test_plain_allow_list_cidr() {
|
||||||
let filter = IpFilter::new(
|
let filter = IpFilter::new(&[plain("10.0.0.0/8")], &[]);
|
||||||
&["10.0.0.0/8".to_string()],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
let allowed: IpAddr = "10.255.255.255".parse().unwrap();
|
let allowed: IpAddr = "10.255.255.255".parse().unwrap();
|
||||||
let denied: IpAddr = "192.168.1.1".parse().unwrap();
|
let denied: IpAddr = "192.168.1.1".parse().unwrap();
|
||||||
assert!(filter.is_allowed(&allowed));
|
assert!(filter.is_allowed(&allowed));
|
||||||
@@ -130,10 +203,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_block_list() {
|
fn test_block_list() {
|
||||||
let filter = IpFilter::new(
|
let filter = IpFilter::new(&[], &["192.168.1.100".to_string()]);
|
||||||
&[],
|
|
||||||
&["192.168.1.100".to_string()],
|
|
||||||
);
|
|
||||||
let blocked: IpAddr = "192.168.1.100".parse().unwrap();
|
let blocked: IpAddr = "192.168.1.100".parse().unwrap();
|
||||||
let allowed: IpAddr = "192.168.1.101".parse().unwrap();
|
let allowed: IpAddr = "192.168.1.101".parse().unwrap();
|
||||||
assert!(!filter.is_allowed(&blocked));
|
assert!(!filter.is_allowed(&blocked));
|
||||||
@@ -143,7 +213,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_block_trumps_allow() {
|
fn test_block_trumps_allow() {
|
||||||
let filter = IpFilter::new(
|
let filter = IpFilter::new(
|
||||||
&["10.0.0.0/8".to_string()],
|
&[plain("10.0.0.0/8")],
|
||||||
&["10.0.0.5".to_string()],
|
&["10.0.0.5".to_string()],
|
||||||
);
|
);
|
||||||
let blocked: IpAddr = "10.0.0.5".parse().unwrap();
|
let blocked: IpAddr = "10.0.0.5".parse().unwrap();
|
||||||
@@ -154,20 +224,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wildcard_allow() {
|
fn test_wildcard_allow() {
|
||||||
let filter = IpFilter::new(
|
let filter = IpFilter::new(&[plain("*")], &[]);
|
||||||
&["*".to_string()],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
||||||
assert!(filter.is_allowed(&ip));
|
assert!(filter.is_allowed(&ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wildcard_block() {
|
fn test_wildcard_block() {
|
||||||
let filter = IpFilter::new(
|
let filter = IpFilter::new(&[], &["*".to_string()]);
|
||||||
&[],
|
|
||||||
&["*".to_string()],
|
|
||||||
);
|
|
||||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
||||||
assert!(!filter.is_allowed(&ip));
|
assert!(!filter.is_allowed(&ip));
|
||||||
}
|
}
|
||||||
@@ -186,4 +250,97 @@ mod tests {
|
|||||||
let normalized = IpFilter::normalize_ip(&ip);
|
let normalized = IpFilter::normalize_ip(&ip);
|
||||||
assert_eq!(normalized, 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ rustproxy-routing = { workspace = true }
|
|||||||
rustproxy-tls = { workspace = true }
|
rustproxy-tls = { workspace = true }
|
||||||
rustproxy-passthrough = { workspace = true }
|
rustproxy-passthrough = { workspace = true }
|
||||||
rustproxy-http = { workspace = true }
|
rustproxy-http = { workspace = true }
|
||||||
rustproxy-nftables = { workspace = true }
|
|
||||||
rustproxy-metrics = { workspace = true }
|
rustproxy-metrics = { workspace = true }
|
||||||
rustproxy-security = { workspace = true }
|
rustproxy-security = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
+134
-119
@@ -7,14 +7,40 @@
|
|||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! use rustproxy::RustProxy;
|
//! use rustproxy::RustProxy;
|
||||||
//! use rustproxy_config::{RustProxyOptions, create_https_passthrough_route};
|
//! use rustproxy_config::*;
|
||||||
//!
|
//!
|
||||||
//! #[tokio::main]
|
//! #[tokio::main]
|
||||||
//! async fn main() -> anyhow::Result<()> {
|
//! async fn main() -> anyhow::Result<()> {
|
||||||
//! let options = RustProxyOptions {
|
//! let options = RustProxyOptions {
|
||||||
//! routes: vec![
|
//! routes: vec![RouteConfig {
|
||||||
//! create_https_passthrough_route("example.com", "backend", 443),
|
//! 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()
|
//! ..Default::default()
|
||||||
//! };
|
//! };
|
||||||
//!
|
//!
|
||||||
@@ -41,16 +67,14 @@ pub use rustproxy_routing;
|
|||||||
pub use rustproxy_passthrough;
|
pub use rustproxy_passthrough;
|
||||||
pub use rustproxy_tls;
|
pub use rustproxy_tls;
|
||||||
pub use rustproxy_http;
|
pub use rustproxy_http;
|
||||||
pub use rustproxy_nftables;
|
|
||||||
pub use rustproxy_metrics;
|
pub use rustproxy_metrics;
|
||||||
pub use rustproxy_security;
|
pub use rustproxy_security;
|
||||||
|
|
||||||
use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec, ForwardingEngine};
|
use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec};
|
||||||
use rustproxy_routing::RouteManager;
|
use rustproxy_routing::RouteManager;
|
||||||
use rustproxy_passthrough::{TcpListenerManager, UdpListenerManager, TlsCertConfig, ConnectionConfig};
|
use rustproxy_passthrough::{TcpListenerManager, UdpListenerManager, TlsCertConfig, ConnectionConfig};
|
||||||
use rustproxy_metrics::{MetricsCollector, Metrics, Statistics};
|
use rustproxy_metrics::{MetricsCollector, Metrics, Statistics};
|
||||||
use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource};
|
use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource};
|
||||||
use rustproxy_nftables::{NftManager, rule_builder};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
/// Certificate status.
|
/// Certificate status.
|
||||||
@@ -74,7 +98,6 @@ pub struct RustProxy {
|
|||||||
challenge_server: Option<challenge_server::ChallengeServer>,
|
challenge_server: Option<challenge_server::ChallengeServer>,
|
||||||
renewal_handle: Option<tokio::task::JoinHandle<()>>,
|
renewal_handle: Option<tokio::task::JoinHandle<()>>,
|
||||||
sampling_handle: Option<tokio::task::JoinHandle<()>>,
|
sampling_handle: Option<tokio::task::JoinHandle<()>>,
|
||||||
nft_manager: Option<NftManager>,
|
|
||||||
started: bool,
|
started: bool,
|
||||||
started_at: Option<Instant>,
|
started_at: Option<Instant>,
|
||||||
/// Shared path to a Unix domain socket for relaying socket-handler connections back to TypeScript.
|
/// Shared path to a Unix domain socket for relaying socket-handler connections back to TypeScript.
|
||||||
@@ -121,7 +144,6 @@ impl RustProxy {
|
|||||||
challenge_server: None,
|
challenge_server: None,
|
||||||
renewal_handle: None,
|
renewal_handle: None,
|
||||||
sampling_handle: None,
|
sampling_handle: None,
|
||||||
nft_manager: None,
|
|
||||||
started: false,
|
started: false,
|
||||||
started_at: None,
|
started_at: None,
|
||||||
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
|
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
|
||||||
@@ -176,7 +198,9 @@ impl RustProxy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(ref allow_list) = default_security.ip_allow_list {
|
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 {
|
if let Some(ref block_list) = default_security.ip_block_list {
|
||||||
security.ip_block_list = Some(block_list.clone());
|
security.ip_block_list = Some(block_list.clone());
|
||||||
@@ -334,12 +358,17 @@ impl RustProxy {
|
|||||||
|
|
||||||
// Bind UDP ports (if any)
|
// Bind UDP ports (if any)
|
||||||
if !udp_ports.is_empty() {
|
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(
|
let mut udp_mgr = UdpListenerManager::new(
|
||||||
Arc::clone(&*self.route_table.load()),
|
Arc::clone(&*self.route_table.load()),
|
||||||
Arc::clone(&self.metrics),
|
Arc::clone(&self.metrics),
|
||||||
conn_tracker,
|
conn_tracker,
|
||||||
self.cancel_token.clone(),
|
self.cancel_token.clone(),
|
||||||
|
route_cancels,
|
||||||
|
connection_registry,
|
||||||
);
|
);
|
||||||
udp_mgr.set_proxy_ips(udp_proxy_ips.clone());
|
udp_mgr.set_proxy_ips(udp_proxy_ips.clone());
|
||||||
|
|
||||||
@@ -363,6 +392,7 @@ impl RustProxy {
|
|||||||
// Start the throughput sampling task with cooperative cancellation
|
// Start the throughput sampling task with cooperative cancellation
|
||||||
let metrics = Arc::clone(&self.metrics);
|
let metrics = Arc::clone(&self.metrics);
|
||||||
let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone();
|
let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone();
|
||||||
|
let http_proxy = self.listener_manager.as_ref().unwrap().http_proxy().clone();
|
||||||
let interval_ms = self.options.metrics.as_ref()
|
let interval_ms = self.options.metrics.as_ref()
|
||||||
.and_then(|m| m.sample_interval_ms)
|
.and_then(|m| m.sample_interval_ms)
|
||||||
.unwrap_or(1000);
|
.unwrap_or(1000);
|
||||||
@@ -378,14 +408,14 @@ impl RustProxy {
|
|||||||
metrics.sample_all();
|
metrics.sample_all();
|
||||||
// Periodically clean up stale rate-limit timestamp entries
|
// Periodically clean up stale rate-limit timestamp entries
|
||||||
conn_tracker.cleanup_stale_timestamps();
|
conn_tracker.cleanup_stale_timestamps();
|
||||||
|
// Clean up expired rate limiter entries to prevent unbounded
|
||||||
|
// growth from unique IPs after traffic stops
|
||||||
|
http_proxy.cleanup_all_rate_limiters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Apply NFTables rules for routes using nftables forwarding engine
|
|
||||||
self.apply_nftables_rules(&self.options.routes.clone()).await;
|
|
||||||
|
|
||||||
// Start renewal timer if ACME is enabled
|
// Start renewal timer if ACME is enabled
|
||||||
self.start_renewal_timer();
|
self.start_renewal_timer();
|
||||||
|
|
||||||
@@ -612,14 +642,6 @@ impl RustProxy {
|
|||||||
}
|
}
|
||||||
self.challenge_server = None;
|
self.challenge_server = None;
|
||||||
|
|
||||||
// Clean up NFTables rules
|
|
||||||
if let Some(ref mut nft) = self.nft_manager {
|
|
||||||
if let Err(e) = nft.cleanup().await {
|
|
||||||
warn!("NFTables cleanup failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.nft_manager = None;
|
|
||||||
|
|
||||||
if let Some(ref mut listener) = self.listener_manager {
|
if let Some(ref mut listener) = self.listener_manager {
|
||||||
listener.graceful_stop().await;
|
listener.graceful_stop().await;
|
||||||
}
|
}
|
||||||
@@ -692,6 +714,9 @@ impl RustProxy {
|
|||||||
.collect();
|
.collect();
|
||||||
self.metrics.retain_backends(&active_backends);
|
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
|
// Atomically swap the route table
|
||||||
let new_manager = Arc::new(new_manager);
|
let new_manager = Arc::new(new_manager);
|
||||||
self.route_table.store(Arc::clone(&new_manager));
|
self.route_table.store(Arc::clone(&new_manager));
|
||||||
@@ -727,9 +752,47 @@ impl RustProxy {
|
|||||||
listener.update_route_manager(Arc::clone(&new_manager));
|
listener.update_route_manager(Arc::clone(&new_manager));
|
||||||
// Cancel connections on routes that were removed or disabled
|
// Cancel connections on routes that were removed or disabled
|
||||||
listener.invalidate_removed_routes(&active_route_ids);
|
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)
|
// Prune HTTP proxy caches (rate limiters, regex cache, round-robin counters)
|
||||||
listener.prune_http_proxy_caches(&active_route_ids);
|
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
|
// Add new ports
|
||||||
for port in &new_ports {
|
for port in &new_ports {
|
||||||
if !old_ports.contains(port) {
|
if !old_ports.contains(port) {
|
||||||
@@ -772,14 +835,22 @@ impl RustProxy {
|
|||||||
if self.udp_listener_manager.is_none() {
|
if self.udp_listener_manager.is_none() {
|
||||||
if let Some(ref listener) = self.listener_manager {
|
if let Some(ref listener) = self.listener_manager {
|
||||||
let conn_tracker = listener.conn_tracker().clone();
|
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 conn_config = Self::build_connection_config(&self.options);
|
||||||
let mut udp_mgr = UdpListenerManager::new(
|
let mut udp_mgr = UdpListenerManager::new(
|
||||||
Arc::clone(&new_manager),
|
Arc::clone(&new_manager),
|
||||||
Arc::clone(&self.metrics),
|
Arc::clone(&self.metrics),
|
||||||
conn_tracker,
|
conn_tracker,
|
||||||
self.cancel_token.clone(),
|
self.cancel_token.clone(),
|
||||||
|
route_cancels,
|
||||||
|
connection_registry,
|
||||||
);
|
);
|
||||||
udp_mgr.set_proxy_ips(conn_config.proxy_ips);
|
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);
|
self.udp_listener_manager = Some(udp_mgr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -821,9 +892,6 @@ impl RustProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update NFTables rules: remove old, apply new
|
|
||||||
self.update_nftables_rules(&routes).await;
|
|
||||||
|
|
||||||
self.options.routes = routes;
|
self.options.routes = routes;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -937,8 +1005,31 @@ impl RustProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get current metrics snapshot.
|
/// Get current metrics snapshot.
|
||||||
|
/// Includes protocol cache entries from the HTTP proxy service.
|
||||||
pub fn get_metrics(&self) -> Metrics {
|
pub fn get_metrics(&self) -> Metrics {
|
||||||
self.metrics.snapshot()
|
let mut metrics = self.metrics.snapshot();
|
||||||
|
if let Some(ref lm) = self.listener_manager {
|
||||||
|
let entries = lm.http_proxy().protocol_cache_snapshot();
|
||||||
|
metrics.detected_protocols = entries.into_iter().map(|e| {
|
||||||
|
rustproxy_metrics::ProtocolCacheEntryMetric {
|
||||||
|
host: e.host,
|
||||||
|
port: e.port,
|
||||||
|
domain: e.domain,
|
||||||
|
protocol: e.protocol,
|
||||||
|
h3_port: e.h3_port,
|
||||||
|
age_secs: e.age_secs,
|
||||||
|
last_accessed_secs: e.last_accessed_secs,
|
||||||
|
last_probed_secs: e.last_probed_secs,
|
||||||
|
h2_suppressed: e.h2_suppressed,
|
||||||
|
h3_suppressed: e.h3_suppressed,
|
||||||
|
h2_cooldown_remaining_secs: e.h2_cooldown_remaining_secs,
|
||||||
|
h3_cooldown_remaining_secs: e.h3_cooldown_remaining_secs,
|
||||||
|
h2_consecutive_failures: e.h2_consecutive_failures,
|
||||||
|
h3_consecutive_failures: e.h3_consecutive_failures,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
}
|
||||||
|
metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a listening port at runtime.
|
/// Add a listening port at runtime.
|
||||||
@@ -1061,6 +1152,10 @@ impl RustProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load a certificate for a domain and hot-swap the TLS configuration.
|
/// 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(
|
pub async fn load_certificate(
|
||||||
&mut self,
|
&mut self,
|
||||||
domain: &str,
|
domain: &str,
|
||||||
@@ -1070,6 +1165,12 @@ impl RustProxy {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!("Loading certificate for domain: {}", domain);
|
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
|
// Store in cert manager if available
|
||||||
if let Some(ref cm_arc) = self.cert_manager {
|
if let Some(ref cm_arc) = self.cert_manager {
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
@@ -1118,103 +1219,17 @@ 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);
|
info!("Certificate loaded and TLS config updated for {}", domain);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get NFTables status.
|
|
||||||
pub async fn get_nftables_status(&self) -> Result<HashMap<String, serde_json::Value>> {
|
|
||||||
match &self.nft_manager {
|
|
||||||
Some(nft) => Ok(nft.status()),
|
|
||||||
None => Ok(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply NFTables rules for routes using the nftables forwarding engine.
|
|
||||||
async fn apply_nftables_rules(&mut self, routes: &[RouteConfig]) {
|
|
||||||
let nft_routes: Vec<&RouteConfig> = routes.iter()
|
|
||||||
.filter(|r| r.action.forwarding_engine.as_ref() == Some(&ForwardingEngine::Nftables))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if nft_routes.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Applying NFTables rules for {} routes", nft_routes.len());
|
|
||||||
|
|
||||||
let table_name = nft_routes.iter()
|
|
||||||
.find_map(|r| r.action.nftables.as_ref()?.table_name.clone())
|
|
||||||
.unwrap_or_else(|| "rustproxy".to_string());
|
|
||||||
|
|
||||||
let mut nft = NftManager::new(Some(table_name));
|
|
||||||
|
|
||||||
for route in &nft_routes {
|
|
||||||
let route_id = route.id.as_deref()
|
|
||||||
.or(route.name.as_deref())
|
|
||||||
.unwrap_or("unnamed");
|
|
||||||
|
|
||||||
let nft_options = match &route.action.nftables {
|
|
||||||
Some(opts) => opts.clone(),
|
|
||||||
None => rustproxy_config::NfTablesOptions {
|
|
||||||
preserve_source_ip: None,
|
|
||||||
protocol: None,
|
|
||||||
max_rate: None,
|
|
||||||
priority: None,
|
|
||||||
table_name: None,
|
|
||||||
use_ip_sets: None,
|
|
||||||
use_advanced_nat: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let targets = match &route.action.targets {
|
|
||||||
Some(targets) => targets,
|
|
||||||
None => {
|
|
||||||
warn!("NFTables route '{}' has no targets, skipping", route_id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let source_ports = route.route_match.ports.to_ports();
|
|
||||||
for target in targets {
|
|
||||||
let target_host = target.host.first().to_string();
|
|
||||||
let target_port_spec = &target.port;
|
|
||||||
|
|
||||||
for &source_port in &source_ports {
|
|
||||||
let resolved_port = target_port_spec.resolve(source_port);
|
|
||||||
let rules = rule_builder::build_dnat_rule(
|
|
||||||
nft.table_name(),
|
|
||||||
"prerouting",
|
|
||||||
source_port,
|
|
||||||
&target_host,
|
|
||||||
resolved_port,
|
|
||||||
&nft_options,
|
|
||||||
);
|
|
||||||
|
|
||||||
let rule_id = format!("{}-{}-{}", route_id, source_port, resolved_port);
|
|
||||||
if let Err(e) = nft.apply_rules(&rule_id, rules).await {
|
|
||||||
error!("Failed to apply NFTables rules for route '{}': {}", route_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.nft_manager = Some(nft);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update NFTables rules when routes change.
|
|
||||||
async fn update_nftables_rules(&mut self, new_routes: &[RouteConfig]) {
|
|
||||||
// Clean up old rules
|
|
||||||
if let Some(ref mut nft) = self.nft_manager {
|
|
||||||
if let Err(e) = nft.cleanup().await {
|
|
||||||
warn!("NFTables cleanup during update failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.nft_manager = None;
|
|
||||||
|
|
||||||
// Apply new rules
|
|
||||||
self.apply_nftables_rules(new_routes).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract TLS configurations from route configs.
|
/// Extract TLS configurations from route configs.
|
||||||
fn extract_tls_configs(routes: &[RouteConfig]) -> HashMap<String, TlsCertConfig> {
|
fn extract_tls_configs(routes: &[RouteConfig]) -> HashMap<String, TlsCertConfig> {
|
||||||
let mut configs = HashMap::new();
|
let mut configs = HashMap::new();
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ async fn handle_request(
|
|||||||
"renewCertificate" => handle_renew_certificate(&id, &request.params, proxy).await,
|
"renewCertificate" => handle_renew_certificate(&id, &request.params, proxy).await,
|
||||||
"getCertificateStatus" => handle_get_certificate_status(&id, &request.params, proxy).await,
|
"getCertificateStatus" => handle_get_certificate_status(&id, &request.params, proxy).await,
|
||||||
"getListeningPorts" => handle_get_listening_ports(&id, proxy),
|
"getListeningPorts" => handle_get_listening_ports(&id, proxy),
|
||||||
"getNftablesStatus" => handle_get_nftables_status(&id, proxy).await,
|
|
||||||
"setSocketHandlerRelay" => handle_set_socket_handler_relay(&id, &request.params, proxy).await,
|
"setSocketHandlerRelay" => handle_set_socket_handler_relay(&id, &request.params, proxy).await,
|
||||||
"setDatagramHandlerRelay" => handle_set_datagram_handler_relay(&id, &request.params, proxy).await,
|
"setDatagramHandlerRelay" => handle_set_datagram_handler_relay(&id, &request.params, proxy).await,
|
||||||
"addListeningPort" => handle_add_listening_port(&id, &request.params, proxy).await,
|
"addListeningPort" => handle_add_listening_port(&id, &request.params, proxy).await,
|
||||||
@@ -352,26 +351,6 @@ fn handle_get_listening_ports(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_get_nftables_status(
|
|
||||||
id: &str,
|
|
||||||
proxy: &Option<RustProxy>,
|
|
||||||
) -> ManagementResponse {
|
|
||||||
match proxy.as_ref() {
|
|
||||||
Some(p) => {
|
|
||||||
match p.get_nftables_status().await {
|
|
||||||
Ok(status) => {
|
|
||||||
match serde_json::to_value(&status) {
|
|
||||||
Ok(v) => ManagementResponse::ok(id.to_string(), v),
|
|
||||||
Err(e) => ManagementResponse::err(id.to_string(), format!("Failed to serialize: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => ManagementResponse::err(id.to_string(), format!("Failed to get status: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => ManagementResponse::ok(id.to_string(), serde_json::json!({})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_set_socket_handler_relay(
|
async fn handle_set_socket_handler_relay(
|
||||||
id: &str,
|
id: &str,
|
||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
|
|||||||
@@ -297,8 +297,6 @@ pub fn make_test_route(
|
|||||||
load_balancing: None,
|
load_balancing: None,
|
||||||
advanced: None,
|
advanced: None,
|
||||||
options: None,
|
options: None,
|
||||||
forwarding_engine: None,
|
|
||||||
nftables: None,
|
|
||||||
send_proxy_protocol: None,
|
send_proxy_protocol: None,
|
||||||
udp: None,
|
udp: None,
|
||||||
},
|
},
|
||||||
|
|||||||
+33
-36
@@ -1,11 +1,5 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
import {
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createHttpRoute,
|
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mergeRouteConfigs,
|
mergeRouteConfigs,
|
||||||
cloneRoute,
|
cloneRoute,
|
||||||
@@ -19,8 +13,11 @@ import {
|
|||||||
|
|
||||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||||
|
|
||||||
tap.test('route creation - createHttpsTerminateRoute produces correct structure', async () => {
|
tap.test('route creation - HTTPS terminate route has correct structure', async () => {
|
||||||
const route = createHttpsTerminateRoute('secure.example.com', { host: '127.0.0.1', port: 8443 });
|
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('match');
|
||||||
expect(route).toHaveProperty('action');
|
expect(route).toHaveProperty('action');
|
||||||
expect(route.action.type).toEqual('forward');
|
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');
|
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 () => {
|
tap.test('route validation - validateRoutes on a set of routes', async () => {
|
||||||
const routes: IRouteConfig[] = [
|
const routes: IRouteConfig[] = [
|
||||||
createHttpRoute('a.com', { host: '127.0.0.1', port: 3000 }),
|
{ match: { ports: 80, domains: 'a.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
|
||||||
createHttpRoute('b.com', { host: '127.0.0.1', port: 4000 }),
|
{ match: { ports: 80, domains: 'b.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] } },
|
||||||
];
|
];
|
||||||
const result = validateRoutes(routes);
|
const result = validateRoutes(routes);
|
||||||
expect(result.valid).toBeTrue();
|
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 () => {
|
tap.test('route validation - validateRoutes catches invalid route in set', async () => {
|
||||||
const routes: any[] = [
|
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
|
{ match: { ports: 80 } }, // missing action
|
||||||
];
|
];
|
||||||
const result = validateRoutes(routes);
|
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 () => {
|
tap.test('path matching - routeMatchesPath with exact path', async () => {
|
||||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const route: IRouteConfig = {
|
||||||
route.match.path = '/api';
|
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, '/api')).toBeTrue();
|
||||||
expect(routeMatchesPath(route, '/other')).toBeFalse();
|
expect(routeMatchesPath(route, '/other')).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('path matching - route without path matches everything', async () => {
|
tap.test('path matching - route without path matches everything', async () => {
|
||||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const route: IRouteConfig = {
|
||||||
// No path set, should match any path
|
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, '/anything')).toBeTrue();
|
||||||
expect(routeMatchesPath(route, '/')).toBeTrue();
|
expect(routeMatchesPath(route, '/')).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
|
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
|
||||||
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const base: IRouteConfig = {
|
||||||
base.priority = 10;
|
match: { ports: 80, domains: 'example.com' },
|
||||||
base.name = 'base-route';
|
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||||
|
priority: 10,
|
||||||
|
name: 'base-route'
|
||||||
|
};
|
||||||
|
|
||||||
const merged = mergeRouteConfigs(base, {
|
const merged = mergeRouteConfigs(base, {
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -85,14 +79,16 @@ tap.test('route merging - mergeRouteConfigs combines routes', async () => {
|
|||||||
|
|
||||||
expect(merged.priority).toEqual(50);
|
expect(merged.priority).toEqual(50);
|
||||||
expect(merged.name).toEqual('merged-route');
|
expect(merged.name).toEqual('merged-route');
|
||||||
// Original route fields should be preserved
|
|
||||||
expect(merged.match.domains).toEqual('example.com');
|
expect(merged.match.domains).toEqual('example.com');
|
||||||
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
|
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
|
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
|
||||||
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const base: IRouteConfig = {
|
||||||
base.name = 'original';
|
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' });
|
const merged = mergeRouteConfigs(base, { name: 'changed' });
|
||||||
expect(base.name).toEqual('original');
|
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 () => {
|
tap.test('route cloning - cloneRoute produces independent copy', async () => {
|
||||||
const original = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const original: IRouteConfig = {
|
||||||
original.priority = 42;
|
match: { ports: 80, domains: 'example.com' },
|
||||||
original.name = 'original-route';
|
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||||
|
priority: 42,
|
||||||
|
name: 'original-route'
|
||||||
|
};
|
||||||
|
|
||||||
const cloned = cloneRoute(original);
|
const cloned = cloneRoute(original);
|
||||||
|
|
||||||
// Should be equal in value
|
|
||||||
expect(cloned.match.domains).toEqual('example.com');
|
expect(cloned.match.domains).toEqual('example.com');
|
||||||
expect(cloned.priority).toEqual(42);
|
expect(cloned.priority).toEqual(42);
|
||||||
expect(cloned.name).toEqual('original-route');
|
expect(cloned.name).toEqual('original-route');
|
||||||
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
|
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
|
||||||
expect(cloned.action.targets![0].port).toEqual(3000);
|
expect(cloned.action.targets![0].port).toEqual(3000);
|
||||||
|
|
||||||
// Should be independent - modifying clone shouldn't affect original
|
|
||||||
cloned.name = 'cloned-route';
|
cloned.name = 'cloned-route';
|
||||||
cloned.priority = 99;
|
cloned.priority = 99;
|
||||||
expect(original.name).toEqual('original-route');
|
expect(original.name).toEqual('original-route');
|
||||||
|
|||||||
+38
-34
@@ -1,11 +1,5 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
import {
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createLoadBalancerRoute,
|
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findMatchingRoutes,
|
findMatchingRoutes,
|
||||||
findBestMatchingRoute,
|
findBestMatchingRoute,
|
||||||
@@ -22,24 +16,11 @@ import {
|
|||||||
|
|
||||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
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 () => {
|
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);
|
const result = validateRouteConfig(route);
|
||||||
expect(result.valid).toBeTrue();
|
expect(result.valid).toBeTrue();
|
||||||
expect(result.errors).toHaveLength(0);
|
expect(result.errors).toHaveLength(0);
|
||||||
@@ -67,30 +48,44 @@ tap.test('route validation - isValidPort checks correctly', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('domain matching - exact domain', 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, 'example.com')).toBeTrue();
|
||||||
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
|
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('domain matching - wildcard domain', async () => {
|
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, 'sub.example.com')).toBeTrue();
|
||||||
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
|
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('port matching - single port', async () => {
|
tap.test('port matching - single port', async () => {
|
||||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const route: IRouteConfig = {
|
||||||
// createHttpRoute defaults to port 80
|
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, 80)).toBeTrue();
|
||||||
expect(routeMatchesPort(route, 443)).toBeFalse();
|
expect(routeMatchesPort(route, 443)).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
|
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
|
||||||
const lowPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const lowPriority: IRouteConfig = {
|
||||||
lowPriority.priority = 10;
|
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 });
|
const highPriority: IRouteConfig = {
|
||||||
highPriority.priority = 100;
|
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 routes: IRouteConfig[] = [lowPriority, highPriority];
|
||||||
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
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 () => {
|
tap.test('route finding - findMatchingRoutes returns all matches', async () => {
|
||||||
const route1 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
const route1: IRouteConfig = {
|
||||||
const route2 = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
|
match: { ports: 80, domains: 'example.com' },
|
||||||
const route3 = createHttpRoute('other.com', { host: '127.0.0.1', port: 5000 });
|
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 });
|
const matches = findMatchingRoutes([route1, route2, route3], { domain: 'example.com', port: 80 });
|
||||||
expect(matches).toHaveLength(2);
|
expect(matches).toHaveLength(2);
|
||||||
|
|||||||
@@ -2,146 +2,101 @@ import * as path from 'path';
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||||
import {
|
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute,
|
|
||||||
createApiRoute,
|
|
||||||
createWebSocketRoute
|
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
|
||||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.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) => {
|
tap.test('Route-based configuration examples', async (tools) => {
|
||||||
// Example 1: HTTP-only configuration
|
const httpOnlyRoute: IRouteConfig = {
|
||||||
const httpOnlyRoute = createHttpRoute(
|
match: { ports: 80, domains: 'http.example.com' },
|
||||||
'http.example.com',
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||||
{
|
name: 'Basic HTTP Route'
|
||||||
host: 'localhost',
|
};
|
||||||
port: 3000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Basic HTTP Route'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('HTTP-only route created successfully:', httpOnlyRoute.name);
|
console.log('HTTP-only route created successfully:', httpOnlyRoute.name);
|
||||||
expect(httpOnlyRoute.action.type).toEqual('forward');
|
expect(httpOnlyRoute.action.type).toEqual('forward');
|
||||||
expect(httpOnlyRoute.match.domains).toEqual('http.example.com');
|
expect(httpOnlyRoute.match.domains).toEqual('http.example.com');
|
||||||
|
|
||||||
// Example 2: HTTPS Passthrough (SNI) configuration
|
const httpsPassthroughRoute: IRouteConfig = {
|
||||||
const httpsPassthroughRoute = createHttpsPassthroughRoute(
|
match: { ports: 443, domains: 'pass.example.com' },
|
||||||
'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'
|
||||||
host: ['10.0.0.1', '10.0.0.2'], // Round-robin target IPs
|
};
|
||||||
port: 443
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'HTTPS Passthrough Route'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(httpsPassthroughRoute).toBeTruthy();
|
expect(httpsPassthroughRoute).toBeTruthy();
|
||||||
expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough');
|
expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough');
|
||||||
expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue();
|
expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue();
|
||||||
|
|
||||||
// Example 3: HTTPS Termination to HTTP Backend
|
const terminateToHttpRoute: IRouteConfig = {
|
||||||
const terminateToHttpRoute = createHttpsTerminateRoute(
|
match: { ports: 443, domains: 'secure.example.com' },
|
||||||
'secure.example.com',
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||||
{
|
name: 'HTTPS Termination to HTTP Backend'
|
||||||
host: 'localhost',
|
};
|
||||||
port: 8080
|
|
||||||
},
|
|
||||||
{
|
|
||||||
certificate: 'auto',
|
|
||||||
name: 'HTTPS Termination to HTTP Backend'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create the HTTP to HTTPS redirect for this domain
|
const httpToHttpsRedirect: IRouteConfig = {
|
||||||
const httpToHttpsRedirect = createHttpToHttpsRedirect(
|
match: { ports: 80, domains: 'secure.example.com' },
|
||||||
'secure.example.com',
|
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
|
||||||
443,
|
name: 'HTTP to HTTPS Redirect for secure.example.com'
|
||||||
{
|
};
|
||||||
name: 'HTTP to HTTPS Redirect for secure.example.com'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(terminateToHttpRoute).toBeTruthy();
|
expect(terminateToHttpRoute).toBeTruthy();
|
||||||
expect(terminateToHttpRoute.action.tls?.mode).toEqual('terminate');
|
expect(terminateToHttpRoute.action.tls?.mode).toEqual('terminate');
|
||||||
expect(httpToHttpsRedirect.action.type).toEqual('socket-handler');
|
|
||||||
|
|
||||||
// Example 4: Load Balancer with HTTPS
|
const loadBalancerRoute: IRouteConfig = {
|
||||||
const loadBalancerRoute = createLoadBalancerRoute(
|
match: { ports: 443, domains: 'proxy.example.com' },
|
||||||
'proxy.example.com',
|
action: {
|
||||||
['internal-api-1.local', 'internal-api-2.local'],
|
type: 'forward',
|
||||||
8443,
|
targets: [
|
||||||
{
|
{ host: 'internal-api-1.local', port: 8443 },
|
||||||
tls: {
|
{ host: 'internal-api-2.local', port: 8443 }
|
||||||
mode: 'terminate-and-reencrypt',
|
],
|
||||||
certificate: 'auto'
|
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
|
||||||
},
|
},
|
||||||
name: 'Load Balanced HTTPS Route'
|
name: 'Load Balanced HTTPS Route'
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
|
||||||
expect(loadBalancerRoute).toBeTruthy();
|
expect(loadBalancerRoute).toBeTruthy();
|
||||||
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||||
expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue();
|
expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue();
|
||||||
|
|
||||||
// Example 5: API Route
|
const apiRoute: IRouteConfig = {
|
||||||
const apiRoute = createApiRoute(
|
match: { ports: 443, domains: 'api.example.com', path: '/api' },
|
||||||
'api.example.com',
|
action: {
|
||||||
'/api',
|
type: 'forward',
|
||||||
{ host: 'localhost', port: 8081 },
|
targets: [{ host: 'localhost', port: 8081 }],
|
||||||
{
|
tls: { mode: 'terminate', certificate: 'auto' }
|
||||||
name: 'API Route',
|
},
|
||||||
useTls: true,
|
name: 'API Route'
|
||||||
addCorsHeaders: true
|
};
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(apiRoute.action.type).toEqual('forward');
|
expect(apiRoute.action.type).toEqual('forward');
|
||||||
expect(apiRoute.match.path).toBeTruthy();
|
expect(apiRoute.match.path).toBeTruthy();
|
||||||
|
|
||||||
// Example 6: Complete HTTPS Server with HTTP Redirect
|
const httpsRoute: IRouteConfig = {
|
||||||
const httpsServerRoutes = createCompleteHttpsServer(
|
match: { ports: 443, domains: 'complete.example.com' },
|
||||||
'complete.example.com',
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||||
{
|
name: 'Complete HTTPS Server'
|
||||||
host: 'localhost',
|
};
|
||||||
port: 8080
|
|
||||||
|
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 }
|
||||||
},
|
},
|
||||||
{
|
name: 'WebSocket Route'
|
||||||
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'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(webSocketRoute.action.type).toEqual('forward');
|
expect(webSocketRoute.action.type).toEqual('forward');
|
||||||
expect(webSocketRoute.action.websocket?.enabled).toBeTrue();
|
expect(webSocketRoute.action.websocket?.enabled).toBeTrue();
|
||||||
|
|
||||||
// Create a SmartProxy instance with all routes
|
|
||||||
const allRoutes: IRouteConfig[] = [
|
const allRoutes: IRouteConfig[] = [
|
||||||
httpOnlyRoute,
|
httpOnlyRoute,
|
||||||
httpsPassthroughRoute,
|
httpsPassthroughRoute,
|
||||||
@@ -149,19 +104,17 @@ tap.test('Route-based configuration examples', async (tools) => {
|
|||||||
httpToHttpsRedirect,
|
httpToHttpsRedirect,
|
||||||
loadBalancerRoute,
|
loadBalancerRoute,
|
||||||
apiRoute,
|
apiRoute,
|
||||||
...httpsServerRoutes,
|
httpsRoute,
|
||||||
|
httpsRedirectRoute,
|
||||||
webSocketRoute
|
webSocketRoute
|
||||||
];
|
];
|
||||||
|
|
||||||
// We're not actually starting the SmartProxy in this test,
|
|
||||||
// just verifying that the configuration is valid
|
|
||||||
const smartProxy = new SmartProxy({
|
const smartProxy = new SmartProxy({
|
||||||
routes: allRoutes
|
routes: allRoutes
|
||||||
});
|
});
|
||||||
|
|
||||||
// Just verify that all routes are configured correctly
|
|
||||||
console.log(`Created ${allRoutes.length} example routes`);
|
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();
|
||||||
+17
-47
@@ -1,27 +1,8 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as plugins from '../ts/plugins.js';
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
// Import route-based helpers
|
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||||
import {
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer
|
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.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 {
|
function findRouteForDomain(routes: any[], domain: string): any {
|
||||||
return routes.find(route => {
|
return routes.find(route => {
|
||||||
const domains = Array.isArray(route.match.domains)
|
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 () => {
|
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.action.type).toEqual('forward');
|
||||||
expect(route.match.domains).toEqual('example.com');
|
expect(route.match.domains).toEqual('example.com');
|
||||||
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
|
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {
|
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.action.type).toEqual('forward');
|
||||||
expect(route.match.domains).toEqual('secure.example.com');
|
expect(route.match.domains).toEqual('secure.example.com');
|
||||||
expect(route.action.tls?.mode).toEqual('terminate');
|
expect(route.action.tls?.mode).toEqual('terminate');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Route Helpers - Create HTTPS passthrough routes', async () => {
|
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.action.type).toEqual('forward');
|
||||||
expect(route.match.domains).toEqual('passthrough.example.com');
|
expect(route.match.domains).toEqual('passthrough.example.com');
|
||||||
expect(route.action.tls?.mode).toEqual('passthrough');
|
expect(route.action.tls?.mode).toEqual('passthrough');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Route Helpers - Create HTTPS to HTTPS routes', async () => {
|
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.action.type).toEqual('forward');
|
||||||
expect(route.match.domains).toEqual('reencrypt.example.com');
|
expect(route.match.domains).toEqual('reencrypt.example.com');
|
||||||
expect(route.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
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();
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
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 { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as child_process from 'child_process';
|
import * as child_process from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||||
|
|
||||||
const exec = promisify(child_process.exec);
|
const exec = promisify(child_process.exec);
|
||||||
|
|
||||||
// Check if we have root privileges to run NFTables tests
|
|
||||||
async function checkRootPrivileges(): Promise<boolean> {
|
async function checkRootPrivileges(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Check if we're running as root
|
|
||||||
const { stdout } = await exec('id -u');
|
const { stdout } = await exec('id -u');
|
||||||
return stdout.trim() === '0';
|
return stdout.trim() === '0';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -17,7 +16,6 @@ async function checkRootPrivileges(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tests should run
|
|
||||||
const isRoot = await checkRootPrivileges();
|
const isRoot = await checkRootPrivileges();
|
||||||
|
|
||||||
if (!isRoot) {
|
if (!isRoot) {
|
||||||
@@ -29,38 +27,45 @@ if (!isRoot) {
|
|||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the test with proper skip condition
|
|
||||||
const testFn = isRoot ? tap.test : tap.skip.test;
|
const testFn = isRoot ? tap.test : tap.skip.test;
|
||||||
|
|
||||||
testFn('NFTables integration tests', async () => {
|
testFn('NFTables integration tests', async () => {
|
||||||
|
|
||||||
console.log('Running NFTables tests with root privileges');
|
console.log('Running NFTables tests with root privileges');
|
||||||
|
|
||||||
// Create test routes
|
const routes: IRouteConfig[] = [
|
||||||
const routes = [
|
{
|
||||||
createNfTablesRoute('tcp-forward', {
|
match: { ports: 9080 },
|
||||||
host: 'localhost',
|
action: {
|
||||||
port: 8080
|
type: 'forward',
|
||||||
}, {
|
forwardingEngine: 'nftables',
|
||||||
ports: 9080,
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
protocol: 'tcp'
|
nftables: { protocol: 'tcp' }
|
||||||
}),
|
},
|
||||||
|
name: 'tcp-forward'
|
||||||
|
},
|
||||||
|
|
||||||
createNfTablesRoute('udp-forward', {
|
{
|
||||||
host: 'localhost',
|
match: { ports: 5354 },
|
||||||
port: 5353
|
action: {
|
||||||
}, {
|
type: 'forward',
|
||||||
ports: 5354,
|
forwardingEngine: 'nftables',
|
||||||
protocol: 'udp'
|
targets: [{ host: 'localhost', port: 5353 }],
|
||||||
}),
|
nftables: { protocol: 'udp' }
|
||||||
|
},
|
||||||
|
name: 'udp-forward'
|
||||||
|
},
|
||||||
|
|
||||||
createNfTablesRoute('port-range', {
|
{
|
||||||
host: 'localhost',
|
match: { ports: [{ from: 9000, to: 9100 }] },
|
||||||
port: 8080
|
action: {
|
||||||
}, {
|
type: 'forward',
|
||||||
ports: [{ from: 9000, to: 9100 }],
|
forwardingEngine: 'nftables',
|
||||||
protocol: 'tcp'
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
})
|
nftables: { protocol: 'tcp' }
|
||||||
|
},
|
||||||
|
name: 'port-range'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const smartProxy = new SmartProxy({
|
const smartProxy = new SmartProxy({
|
||||||
@@ -68,15 +73,12 @@ testFn('NFTables integration tests', async () => {
|
|||||||
routes
|
routes
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the proxy
|
|
||||||
await smartProxy.start();
|
await smartProxy.start();
|
||||||
console.log('SmartProxy started with NFTables routes');
|
console.log('SmartProxy started with NFTables routes');
|
||||||
|
|
||||||
// Get NFTables status
|
|
||||||
const status = await smartProxy.getNfTablesStatus();
|
const status = await smartProxy.getNfTablesStatus();
|
||||||
console.log('NFTables status:', JSON.stringify(status, null, 2));
|
console.log('NFTables status:', JSON.stringify(status, null, 2));
|
||||||
|
|
||||||
// Verify all routes are provisioned
|
|
||||||
expect(Object.keys(status).length).toEqual(routes.length);
|
expect(Object.keys(status).length).toEqual(routes.length);
|
||||||
|
|
||||||
for (const routeStatus of Object.values(status)) {
|
for (const routeStatus of Object.values(status)) {
|
||||||
@@ -84,11 +86,9 @@ testFn('NFTables integration tests', async () => {
|
|||||||
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
|
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the proxy
|
|
||||||
await smartProxy.stop();
|
await smartProxy.stop();
|
||||||
console.log('SmartProxy stopped');
|
console.log('SmartProxy stopped');
|
||||||
|
|
||||||
// Verify all rules are cleaned up
|
|
||||||
const finalStatus = await smartProxy.getNfTablesStatus();
|
const finalStatus = await smartProxy.getNfTablesStatus();
|
||||||
expect(Object.keys(finalStatus).length).toEqual(0);
|
expect(Object.keys(finalStatus).length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
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 { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
@@ -10,13 +9,13 @@ import { fileURLToPath } from 'url';
|
|||||||
import * as child_process from 'child_process';
|
import * as child_process from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||||
|
|
||||||
const exec = promisify(child_process.exec);
|
const exec = promisify(child_process.exec);
|
||||||
|
|
||||||
// Get __dirname equivalent for ES modules
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
// Check if we have root privileges
|
|
||||||
async function checkRootPrivileges(): Promise<boolean> {
|
async function checkRootPrivileges(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await exec('id -u');
|
const { stdout } = await exec('id -u');
|
||||||
@@ -26,7 +25,6 @@ async function checkRootPrivileges(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tests should run
|
|
||||||
const runTests = await checkRootPrivileges();
|
const runTests = await checkRootPrivileges();
|
||||||
|
|
||||||
if (!runTests) {
|
if (!runTests) {
|
||||||
@@ -36,10 +34,8 @@ if (!runTests) {
|
|||||||
console.log('Skipping NFTables integration tests');
|
console.log('Skipping NFTables integration tests');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
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 testTcpServer: net.Server;
|
||||||
let testHttpServer: http.Server;
|
let testHttpServer: http.Server;
|
||||||
let testHttpsServer: https.Server;
|
let testHttpsServer: https.Server;
|
||||||
@@ -53,10 +49,8 @@ const PROXY_HTTP_PORT = 5001;
|
|||||||
const PROXY_HTTPS_PORT = 5002;
|
const PROXY_HTTPS_PORT = 5002;
|
||||||
const TEST_DATA = 'Hello through NFTables!';
|
const TEST_DATA = 'Hello through NFTables!';
|
||||||
|
|
||||||
// Helper to create test certificates
|
|
||||||
async function createTestCertificates() {
|
async function createTestCertificates() {
|
||||||
try {
|
try {
|
||||||
// Import the certificate helper
|
|
||||||
const certsModule = await import('./helpers/certificates.js');
|
const certsModule = await import('./helpers/certificates.js');
|
||||||
const certificates = certsModule.loadTestCertificates();
|
const certificates = certsModule.loadTestCertificates();
|
||||||
return {
|
return {
|
||||||
@@ -65,7 +59,6 @@ async function createTestCertificates() {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load test certificates:', err);
|
console.error('Failed to load test certificates:', err);
|
||||||
// Use dummy certificates for testing
|
|
||||||
return {
|
return {
|
||||||
cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'),
|
cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'),
|
||||||
key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8')
|
key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8')
|
||||||
@@ -76,7 +69,6 @@ async function createTestCertificates() {
|
|||||||
tap.skip.test('setup NFTables integration test environment', async () => {
|
tap.skip.test('setup NFTables integration test environment', async () => {
|
||||||
console.log('Running NFTables integration tests with root privileges');
|
console.log('Running NFTables integration tests with root privileges');
|
||||||
|
|
||||||
// Create a basic TCP test server
|
|
||||||
testTcpServer = net.createServer((socket) => {
|
testTcpServer = net.createServer((socket) => {
|
||||||
socket.on('data', (data) => {
|
socket.on('data', (data) => {
|
||||||
socket.write(`Server says: ${data.toString()}`);
|
socket.write(`Server says: ${data.toString()}`);
|
||||||
@@ -90,7 +82,6 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an HTTP test server
|
|
||||||
testHttpServer = http.createServer((req, res) => {
|
testHttpServer = http.createServer((req, res) => {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
res.end(`HTTP Server says: ${TEST_DATA}`);
|
res.end(`HTTP Server says: ${TEST_DATA}`);
|
||||||
@@ -103,7 +94,6 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an HTTPS test server
|
|
||||||
const certs = await createTestCertificates();
|
const certs = await createTestCertificates();
|
||||||
testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => {
|
testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
@@ -117,69 +107,73 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create SmartProxy with various NFTables routes
|
|
||||||
smartProxy = new SmartProxy({
|
smartProxy = new SmartProxy({
|
||||||
enableDetailedLogging: true,
|
enableDetailedLogging: true,
|
||||||
routes: [
|
routes: [
|
||||||
// TCP forwarding route
|
{
|
||||||
createNfTablesRoute('tcp-nftables', {
|
match: { ports: PROXY_TCP_PORT },
|
||||||
host: 'localhost',
|
action: {
|
||||||
port: TEST_TCP_PORT
|
type: 'forward',
|
||||||
}, {
|
forwardingEngine: 'nftables',
|
||||||
ports: PROXY_TCP_PORT,
|
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||||
protocol: 'tcp'
|
nftables: { protocol: 'tcp' }
|
||||||
}),
|
},
|
||||||
|
name: 'tcp-nftables'
|
||||||
|
},
|
||||||
|
|
||||||
// HTTP forwarding route
|
{
|
||||||
createNfTablesRoute('http-nftables', {
|
match: { ports: PROXY_HTTP_PORT },
|
||||||
host: 'localhost',
|
action: {
|
||||||
port: TEST_HTTP_PORT
|
type: 'forward',
|
||||||
}, {
|
forwardingEngine: 'nftables',
|
||||||
ports: PROXY_HTTP_PORT,
|
targets: [{ host: 'localhost', port: TEST_HTTP_PORT }],
|
||||||
protocol: 'tcp'
|
nftables: { protocol: 'tcp' }
|
||||||
}),
|
},
|
||||||
|
name: 'http-nftables'
|
||||||
|
},
|
||||||
|
|
||||||
// HTTPS termination route
|
{
|
||||||
createNfTablesTerminateRoute('https-nftables.example.com', {
|
match: { ports: PROXY_HTTPS_PORT, domains: 'https-nftables.example.com' },
|
||||||
host: 'localhost',
|
action: {
|
||||||
port: TEST_HTTPS_PORT
|
type: 'forward',
|
||||||
}, {
|
forwardingEngine: 'nftables',
|
||||||
ports: PROXY_HTTPS_PORT,
|
targets: [{ host: 'localhost', port: TEST_HTTPS_PORT }],
|
||||||
protocol: 'tcp',
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
certificate: certs
|
nftables: { protocol: 'tcp' }
|
||||||
}),
|
},
|
||||||
|
name: 'https-nftables'
|
||||||
|
},
|
||||||
|
|
||||||
// Route with IP allow list
|
{
|
||||||
createNfTablesRoute('secure-tcp', {
|
match: { ports: 5003 },
|
||||||
host: 'localhost',
|
action: {
|
||||||
port: TEST_TCP_PORT
|
type: 'forward',
|
||||||
}, {
|
forwardingEngine: 'nftables',
|
||||||
ports: 5003,
|
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||||
protocol: 'tcp',
|
nftables: { protocol: 'tcp', ipAllowList: ['127.0.0.1', '::1'] }
|
||||||
ipAllowList: ['127.0.0.1', '::1']
|
},
|
||||||
}),
|
name: 'secure-tcp'
|
||||||
|
},
|
||||||
|
|
||||||
// Route with QoS settings
|
{
|
||||||
createNfTablesRoute('qos-tcp', {
|
match: { ports: 5004 },
|
||||||
host: 'localhost',
|
action: {
|
||||||
port: TEST_TCP_PORT
|
type: 'forward',
|
||||||
}, {
|
forwardingEngine: 'nftables',
|
||||||
ports: 5004,
|
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||||
protocol: 'tcp',
|
nftables: { protocol: 'tcp', maxRate: '10mbps', priority: 1 }
|
||||||
maxRate: '10mbps',
|
},
|
||||||
priority: 1
|
name: 'qos-tcp'
|
||||||
})
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('SmartProxy created, now starting...');
|
console.log('SmartProxy created, now starting...');
|
||||||
|
|
||||||
// Start the proxy
|
|
||||||
try {
|
try {
|
||||||
await smartProxy.start();
|
await smartProxy.start();
|
||||||
console.log('SmartProxy started successfully');
|
console.log('SmartProxy started successfully');
|
||||||
|
|
||||||
// Verify proxy is listening on expected ports
|
|
||||||
const listeningPorts = smartProxy.getListeningPorts();
|
const listeningPorts = smartProxy.getListeningPorts();
|
||||||
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
|
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -191,7 +185,6 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
|||||||
tap.skip.test('should forward TCP connections through NFTables', async () => {
|
tap.skip.test('should forward TCP connections through NFTables', async () => {
|
||||||
console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`);
|
console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`);
|
||||||
|
|
||||||
// First verify our test server is running
|
|
||||||
try {
|
try {
|
||||||
const testClient = new net.Socket();
|
const testClient = new net.Socket();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -206,7 +199,6 @@ tap.skip.test('should forward TCP connections through NFTables', async () => {
|
|||||||
console.error(`Test server on port ${TEST_TCP_PORT} is not accessible: ${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 client = new net.Socket();
|
||||||
|
|
||||||
const response = await new Promise<string>((resolve, reject) => {
|
const response = await new Promise<string>((resolve, reject) => {
|
||||||
@@ -259,14 +251,13 @@ tap.skip.test('should forward HTTP connections through NFTables', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.skip.test('should handle HTTPS termination with NFTables', async () => {
|
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 response = await new Promise<string>((resolve, reject) => {
|
||||||
const options = {
|
const options = {
|
||||||
hostname: 'localhost',
|
hostname: 'localhost',
|
||||||
port: PROXY_HTTPS_PORT,
|
port: PROXY_HTTPS_PORT,
|
||||||
path: '/',
|
path: '/',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
rejectUnauthorized: false // For self-signed cert
|
rejectUnauthorized: false
|
||||||
};
|
};
|
||||||
|
|
||||||
https.get(options, (res) => {
|
https.get(options, (res) => {
|
||||||
@@ -284,7 +275,6 @@ tap.skip.test('should handle HTTPS termination with NFTables', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.skip.test('should respect IP allow lists in NFTables', async () => {
|
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 client = new net.Socket();
|
||||||
|
|
||||||
const connected = await new Promise<boolean>((resolve) => {
|
const connected = await new Promise<boolean>((resolve) => {
|
||||||
@@ -311,11 +301,9 @@ tap.skip.test('should respect IP allow lists in NFTables', async () => {
|
|||||||
tap.skip.test('should get NFTables status', async () => {
|
tap.skip.test('should get NFTables status', async () => {
|
||||||
const status = await smartProxy.getNfTablesStatus();
|
const status = await smartProxy.getNfTablesStatus();
|
||||||
|
|
||||||
// Check that we have status for our routes
|
|
||||||
const statusKeys = Object.keys(status);
|
const statusKeys = Object.keys(status);
|
||||||
expect(statusKeys.length).toBeGreaterThan(0);
|
expect(statusKeys.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Check status structure for one of the routes
|
|
||||||
const firstStatus = status[statusKeys[0]];
|
const firstStatus = status[statusKeys[0]];
|
||||||
expect(firstStatus).toHaveProperty('active');
|
expect(firstStatus).toHaveProperty('active');
|
||||||
expect(firstStatus).toHaveProperty('ruleCount');
|
expect(firstStatus).toHaveProperty('ruleCount');
|
||||||
@@ -324,7 +312,6 @@ tap.skip.test('should get NFTables status', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.skip.test('cleanup NFTables integration test environment', async () => {
|
tap.skip.test('cleanup NFTables integration test environment', async () => {
|
||||||
// Stop the proxy and test servers
|
|
||||||
await smartProxy.stop();
|
await smartProxy.stop();
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
|||||||
+61
-97
@@ -1,30 +1,20 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
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 type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||||
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
||||||
|
|
||||||
// Test server and client utilities
|
|
||||||
let testServers: Array<{ server: net.Server; port: number }> = [];
|
let testServers: Array<{ server: net.Server; port: number }> = [];
|
||||||
let smartProxy: SmartProxy;
|
let smartProxy: SmartProxy;
|
||||||
|
|
||||||
let TEST_PORTS: number[]; // 3 test server ports
|
let TEST_PORTS: number[];
|
||||||
let PROXY_PORTS: number[]; // 6 proxy ports
|
let PROXY_PORTS: number[];
|
||||||
const TEST_DATA = 'Hello through dynamic port mapper!';
|
const TEST_DATA = 'Hello through dynamic port mapper!';
|
||||||
|
|
||||||
// Cleanup function to close all servers and proxies
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
console.log('Starting cleanup...');
|
console.log('Starting cleanup...');
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
||||||
// Close test servers
|
|
||||||
for (const { server, port } of testServers) {
|
for (const { server, port } of testServers) {
|
||||||
promises.push(new Promise<void>(resolve => {
|
promises.push(new Promise<void>(resolve => {
|
||||||
console.log(`Closing test server on port ${port}`);
|
console.log(`Closing test server on port ${port}`);
|
||||||
@@ -35,7 +25,6 @@ function cleanup() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop SmartProxy
|
|
||||||
if (smartProxy) {
|
if (smartProxy) {
|
||||||
console.log('Stopping SmartProxy...');
|
console.log('Stopping SmartProxy...');
|
||||||
promises.push(smartProxy.stop().then(() => {
|
promises.push(smartProxy.stop().then(() => {
|
||||||
@@ -46,12 +35,10 @@ function cleanup() {
|
|||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Creates a test TCP server that listens on a given port
|
|
||||||
function createTestServer(port: number): Promise<net.Server> {
|
function createTestServer(port: number): Promise<net.Server> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer((socket) => {
|
const server = net.createServer((socket) => {
|
||||||
socket.on('data', (data) => {
|
socket.on('data', (data) => {
|
||||||
// Echo the received data back with a server identifier
|
|
||||||
socket.write(`Server ${port} says: ${data.toString()}`);
|
socket.write(`Server ${port} says: ${data.toString()}`);
|
||||||
});
|
});
|
||||||
socket.on('error', (error) => {
|
socket.on('error', (error) => {
|
||||||
@@ -67,7 +54,6 @@ function createTestServer(port: number): Promise<net.Server> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Creates a test client connection with timeout
|
|
||||||
function createTestClient(port: number, data: string): Promise<string> {
|
function createTestClient(port: number, data: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const client = new net.Socket();
|
const client = new net.Socket();
|
||||||
@@ -100,123 +86,108 @@ function createTestClient(port: number, data: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up test environment
|
|
||||||
tap.test('setup port mapping test environment', async () => {
|
tap.test('setup port mapping test environment', async () => {
|
||||||
const allPorts = await findFreePorts(9);
|
const allPorts = await findFreePorts(9);
|
||||||
TEST_PORTS = allPorts.slice(0, 3);
|
TEST_PORTS = allPorts.slice(0, 3);
|
||||||
PROXY_PORTS = allPorts.slice(3, 9);
|
PROXY_PORTS = allPorts.slice(3, 9);
|
||||||
|
|
||||||
// Create multiple test servers on different ports
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createTestServer(TEST_PORTS[0]),
|
createTestServer(TEST_PORTS[0]),
|
||||||
createTestServer(TEST_PORTS[1]),
|
createTestServer(TEST_PORTS[1]),
|
||||||
createTestServer(TEST_PORTS[2]),
|
createTestServer(TEST_PORTS[2]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Compute dynamic offset between proxy and test ports
|
|
||||||
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
|
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
|
||||||
|
|
||||||
// Create a SmartProxy with dynamic port mapping routes
|
|
||||||
smartProxy = new SmartProxy({
|
smartProxy = new SmartProxy({
|
||||||
routes: [
|
routes: [
|
||||||
// Simple function that returns the same port (identity mapping)
|
{
|
||||||
createPortMappingRoute({
|
match: { ports: PROXY_PORTS[0] },
|
||||||
sourcePortRange: PROXY_PORTS[0],
|
action: {
|
||||||
targetHost: 'localhost',
|
type: 'forward',
|
||||||
portMapper: (context) => TEST_PORTS[0],
|
targets: [{
|
||||||
name: 'Identity Port Mapping'
|
host: 'localhost',
|
||||||
}),
|
port: (context: IRouteContext) => TEST_PORTS[0]
|
||||||
|
}]
|
||||||
// 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';
|
|
||||||
},
|
},
|
||||||
portMapper: (context) => {
|
name: 'Identity Port Mapping'
|
||||||
// Port mapping logic based on incoming port
|
},
|
||||||
if (context.port === PROXY_PORTS[2]) {
|
|
||||||
return TEST_PORTS[0];
|
{
|
||||||
} else {
|
match: { ports: PROXY_PORTS[1] },
|
||||||
return TEST_PORTS[2];
|
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'
|
name: 'Dynamic Host and Port Mapping'
|
||||||
}),
|
},
|
||||||
|
|
||||||
// Smart load balancer for domain-based routing
|
{
|
||||||
createSmartLoadBalancer({
|
match: { ports: PROXY_PORTS[4] },
|
||||||
ports: PROXY_PORTS[4],
|
action: {
|
||||||
domainTargets: {
|
type: 'forward',
|
||||||
'test1.example.com': 'localhost',
|
targets: [{
|
||||||
'test2.example.com': '127.0.0.1'
|
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'
|
name: 'Smart Domain Load Balancer'
|
||||||
})
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the SmartProxy
|
|
||||||
await smartProxy.start();
|
await smartProxy.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 1: Simple identity port mapping
|
|
||||||
tap.test('should map port using identity function', async () => {
|
tap.test('should map port using identity function', async () => {
|
||||||
const response = await createTestClient(PROXY_PORTS[0], TEST_DATA);
|
const response = await createTestClient(PROXY_PORTS[0], TEST_DATA);
|
||||||
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${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 () => {
|
tap.test('should map port using offset function', async () => {
|
||||||
const response = await createTestClient(PROXY_PORTS[1], TEST_DATA);
|
const response = await createTestClient(PROXY_PORTS[1], TEST_DATA);
|
||||||
expect(response).toEqual(`Server ${TEST_PORTS[1]} says: ${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 () => {
|
tap.test('should map port using dynamic logic', async () => {
|
||||||
const response = await createTestClient(PROXY_PORTS[2], TEST_DATA);
|
const response = await createTestClient(PROXY_PORTS[2], TEST_DATA);
|
||||||
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${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 () => {
|
tap.test('should handle errors in port mapping functions', async () => {
|
||||||
// Create a route with a function that throws an error
|
|
||||||
const errorRoute: IRouteConfig = {
|
const errorRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
ports: PROXY_PORTS[5]
|
ports: PROXY_PORTS[5]
|
||||||
@@ -233,23 +204,17 @@ tap.test('should handle errors in port mapping functions', async () => {
|
|||||||
name: 'Error Route'
|
name: 'Error Route'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the route to SmartProxy
|
|
||||||
await smartProxy.updateRoutes([...smartProxy.settings.routes, errorRoute]);
|
await smartProxy.updateRoutes([...smartProxy.settings.routes, errorRoute]);
|
||||||
|
|
||||||
// The connection should fail or timeout
|
|
||||||
try {
|
try {
|
||||||
await createTestClient(PROXY_PORTS[5], TEST_DATA);
|
await createTestClient(PROXY_PORTS[5], TEST_DATA);
|
||||||
// Connection should not succeed
|
|
||||||
expect(false).toBeTrue();
|
expect(false).toBeTrue();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Connection failed as expected
|
|
||||||
expect(true).toBeTrue();
|
expect(true).toBeTrue();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
tap.test('cleanup port mapping test environment', async () => {
|
tap.test('cleanup port mapping test environment', async () => {
|
||||||
// Add timeout to prevent hanging if SmartProxy shutdown has issues
|
|
||||||
const cleanupPromise = cleanup();
|
const cleanupPromise = cleanup();
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Cleanup timeout after 5 seconds')), 5000)
|
setTimeout(() => reject(new Error('Cleanup timeout after 5 seconds')), 5000)
|
||||||
@@ -259,7 +224,6 @@ tap.test('cleanup port mapping test environment', async () => {
|
|||||||
await Promise.race([cleanupPromise, timeoutPromise]);
|
await Promise.race([cleanupPromise, timeoutPromise]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Cleanup error:', error);
|
console.error('Cleanup error:', error);
|
||||||
// Force cleanup even if there's an error
|
|
||||||
testServers = [];
|
testServers = [];
|
||||||
smartProxy = null as any;
|
smartProxy = null as any;
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-184
@@ -6,7 +6,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|||||||
// Import from core modules
|
// Import from core modules
|
||||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||||
|
|
||||||
// Import route utilities and helpers
|
// Import route utilities
|
||||||
import {
|
import {
|
||||||
findMatchingRoutes,
|
findMatchingRoutes,
|
||||||
findBestMatchingRoute,
|
findBestMatchingRoute,
|
||||||
@@ -28,16 +28,7 @@ import {
|
|||||||
assertValidRoute
|
assertValidRoute
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||||
|
|
||||||
import {
|
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute,
|
|
||||||
createApiRoute,
|
|
||||||
createWebSocketRoute
|
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
|
||||||
|
|
||||||
// Import test helpers
|
// Import test helpers
|
||||||
import { loadTestCertificates } from './helpers/certificates.js';
|
import { loadTestCertificates } from './helpers/certificates.js';
|
||||||
@@ -47,12 +38,12 @@ import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.
|
|||||||
// --------------------------------- Route Creation Tests ---------------------------------
|
// --------------------------------- Route Creation Tests ---------------------------------
|
||||||
|
|
||||||
tap.test('Routes: Should create basic HTTP route', async () => {
|
tap.test('Routes: Should create basic HTTP route', async () => {
|
||||||
// Create a simple HTTP route
|
const httpRoute: IRouteConfig = {
|
||||||
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }, {
|
match: { ports: 80, domains: 'example.com' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||||
name: 'Basic HTTP Route'
|
name: 'Basic HTTP Route'
|
||||||
});
|
};
|
||||||
|
|
||||||
// Validate the route configuration
|
|
||||||
expect(httpRoute.match.ports).toEqual(80);
|
expect(httpRoute.match.ports).toEqual(80);
|
||||||
expect(httpRoute.match.domains).toEqual('example.com');
|
expect(httpRoute.match.domains).toEqual('example.com');
|
||||||
expect(httpRoute.action.type).toEqual('forward');
|
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 () => {
|
tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
|
||||||
// Create an HTTPS route with TLS termination
|
const httpsRoute: IRouteConfig = {
|
||||||
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8080 }, {
|
match: { ports: 443, domains: 'secure.example.com' },
|
||||||
certificate: 'auto',
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' }
|
||||||
|
},
|
||||||
name: 'HTTPS Route'
|
name: 'HTTPS Route'
|
||||||
});
|
};
|
||||||
|
|
||||||
// Validate the route configuration
|
expect(httpsRoute.match.ports).toEqual(443);
|
||||||
expect(httpsRoute.match.ports).toEqual(443); // Default HTTPS port
|
|
||||||
expect(httpsRoute.match.domains).toEqual('secure.example.com');
|
expect(httpsRoute.match.domains).toEqual('secure.example.com');
|
||||||
expect(httpsRoute.action.type).toEqual('forward');
|
expect(httpsRoute.action.type).toEqual('forward');
|
||||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
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 () => {
|
tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
|
||||||
// Create an HTTP to HTTPS redirect
|
const redirectRoute: IRouteConfig = {
|
||||||
const redirectRoute = createHttpToHttpsRedirect('example.com', 443);
|
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.ports).toEqual(80);
|
||||||
expect(redirectRoute.match.domains).toEqual('example.com');
|
expect(redirectRoute.match.domains).toEqual('example.com');
|
||||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
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 () => {
|
tap.test('Routes: Should create complete HTTPS server with redirects', async () => {
|
||||||
// Create a complete HTTPS server setup
|
const routes: IRouteConfig[] = [
|
||||||
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 8080 }, {
|
{
|
||||||
certificate: 'auto'
|
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);
|
expect(routes.length).toEqual(2);
|
||||||
|
|
||||||
// Validate HTTPS route
|
|
||||||
const httpsRoute = routes[0];
|
const httpsRoute = routes[0];
|
||||||
expect(httpsRoute.match.ports).toEqual(443);
|
expect(httpsRoute.match.ports).toEqual(443);
|
||||||
expect(httpsRoute.match.domains).toEqual('example.com');
|
expect(httpsRoute.match.domains).toEqual('example.com');
|
||||||
expect(httpsRoute.action.type).toEqual('forward');
|
expect(httpsRoute.action.type).toEqual('forward');
|
||||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||||
|
|
||||||
// Validate HTTP redirect route
|
|
||||||
const redirectRoute = routes[1];
|
const redirectRoute = routes[1];
|
||||||
expect(redirectRoute.match.ports).toEqual(80);
|
expect(redirectRoute.match.ports).toEqual(80);
|
||||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
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 () => {
|
tap.test('Routes: Should create load balancer route', async () => {
|
||||||
// Create a load balancer route
|
const lbRoute: IRouteConfig = {
|
||||||
const lbRoute = createLoadBalancerRoute(
|
match: { ports: 443, domains: 'app.example.com' },
|
||||||
'app.example.com',
|
action: {
|
||||||
['10.0.0.1', '10.0.0.2', '10.0.0.3'],
|
type: 'forward',
|
||||||
8080,
|
targets: [{ host: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], port: 8080 }],
|
||||||
{
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
tls: {
|
loadBalancing: { algorithm: 'round-robin' }
|
||||||
mode: 'terminate',
|
},
|
||||||
certificate: 'auto'
|
name: 'Load Balanced Route'
|
||||||
},
|
};
|
||||||
name: 'Load Balanced Route'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validate the route configuration
|
|
||||||
expect(lbRoute.match.domains).toEqual('app.example.com');
|
expect(lbRoute.match.domains).toEqual('app.example.com');
|
||||||
expect(lbRoute.action.type).toEqual('forward');
|
expect(lbRoute.action.type).toEqual('forward');
|
||||||
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
|
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
|
||||||
@@ -139,15 +146,25 @@ tap.test('Routes: Should create load balancer route', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Routes: Should create API route with CORS', async () => {
|
tap.test('Routes: Should create API route with CORS', async () => {
|
||||||
// Create an API route with CORS headers
|
const apiRoute: IRouteConfig = {
|
||||||
const apiRoute = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
|
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||||
useTls: true,
|
action: {
|
||||||
certificate: 'auto',
|
type: 'forward',
|
||||||
addCorsHeaders: true,
|
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'
|
name: 'API Route'
|
||||||
});
|
};
|
||||||
|
|
||||||
// Validate the route configuration
|
|
||||||
expect(apiRoute.match.domains).toEqual('api.example.com');
|
expect(apiRoute.match.domains).toEqual('api.example.com');
|
||||||
expect(apiRoute.match.path).toEqual('/v1/*');
|
expect(apiRoute.match.path).toEqual('/v1/*');
|
||||||
expect(apiRoute.action.type).toEqual('forward');
|
expect(apiRoute.action.type).toEqual('forward');
|
||||||
@@ -155,7 +172,6 @@ tap.test('Routes: Should create API route with CORS', async () => {
|
|||||||
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
|
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||||
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
|
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||||
|
|
||||||
// Check CORS headers
|
|
||||||
expect(apiRoute.headers).toBeDefined();
|
expect(apiRoute.headers).toBeDefined();
|
||||||
if (apiRoute.headers?.response) {
|
if (apiRoute.headers?.response) {
|
||||||
expect(apiRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
expect(apiRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
||||||
@@ -164,15 +180,18 @@ tap.test('Routes: Should create API route with CORS', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Routes: Should create WebSocket route', async () => {
|
tap.test('Routes: Should create WebSocket route', async () => {
|
||||||
// Create a WebSocket route
|
const wsRoute: IRouteConfig = {
|
||||||
const wsRoute = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 5000 }, {
|
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||||
useTls: true,
|
action: {
|
||||||
certificate: 'auto',
|
type: 'forward',
|
||||||
pingInterval: 15000,
|
targets: [{ host: 'localhost', port: 5000 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' },
|
||||||
|
websocket: { enabled: true, pingInterval: 15000 }
|
||||||
|
},
|
||||||
|
priority: 100,
|
||||||
name: 'WebSocket Route'
|
name: 'WebSocket Route'
|
||||||
});
|
};
|
||||||
|
|
||||||
// Validate the route configuration
|
|
||||||
expect(wsRoute.match.domains).toEqual('ws.example.com');
|
expect(wsRoute.match.domains).toEqual('ws.example.com');
|
||||||
expect(wsRoute.match.path).toEqual('/socket');
|
expect(wsRoute.match.path).toEqual('/socket');
|
||||||
expect(wsRoute.action.type).toEqual('forward');
|
expect(wsRoute.action.type).toEqual('forward');
|
||||||
@@ -180,7 +199,6 @@ tap.test('Routes: Should create WebSocket route', async () => {
|
|||||||
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
|
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||||
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
|
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
|
||||||
|
|
||||||
// Check WebSocket configuration
|
|
||||||
expect(wsRoute.action.websocket).toBeDefined();
|
expect(wsRoute.action.websocket).toBeDefined();
|
||||||
if (wsRoute.action.websocket) {
|
if (wsRoute.action.websocket) {
|
||||||
expect(wsRoute.action.websocket.enabled).toBeTrue();
|
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
|
// Static file serving has been removed - should be handled by external servers
|
||||||
|
|
||||||
tap.test('SmartProxy: Should create instance with route-based config', async () => {
|
tap.test('SmartProxy: Should create instance with route-based config', async () => {
|
||||||
// Create TLS certificates for testing
|
|
||||||
const certs = loadTestCertificates();
|
const certs = loadTestCertificates();
|
||||||
|
|
||||||
// Create a SmartProxy instance with route-based configuration
|
|
||||||
const proxy = new SmartProxy({
|
const proxy = new SmartProxy({
|
||||||
routes: [
|
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'
|
name: 'HTTP Route'
|
||||||
}),
|
},
|
||||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8443 }, {
|
{
|
||||||
certificate: {
|
match: { ports: 443, domains: 'secure.example.com' },
|
||||||
key: certs.privateKey,
|
action: {
|
||||||
cert: certs.publicKey
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 8443 }],
|
||||||
|
tls: {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: { key: certs.privateKey, cert: certs.publicKey }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
name: 'HTTPS Route'
|
name: 'HTTPS Route'
|
||||||
})
|
}
|
||||||
],
|
],
|
||||||
defaults: {
|
defaults: {
|
||||||
target: {
|
target: {
|
||||||
@@ -218,13 +241,11 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
|||||||
maxConnections: 100
|
maxConnections: 100
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Additional settings
|
|
||||||
initialDataTimeout: 10000,
|
initialDataTimeout: 10000,
|
||||||
inactivityTimeout: 300000,
|
inactivityTimeout: 300000,
|
||||||
enableDetailedLogging: true
|
enableDetailedLogging: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simply verify the instance was created successfully
|
|
||||||
expect(typeof proxy).toEqual('object');
|
expect(typeof proxy).toEqual('object');
|
||||||
expect(typeof proxy.start).toEqual('function');
|
expect(typeof proxy.start).toEqual('function');
|
||||||
expect(typeof proxy.stop).toEqual('function');
|
expect(typeof proxy.stop).toEqual('function');
|
||||||
@@ -233,7 +254,6 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
|||||||
// --------------------------------- Edge Case Tests ---------------------------------
|
// --------------------------------- Edge Case Tests ---------------------------------
|
||||||
|
|
||||||
tap.test('Edge Case - Empty Routes Array', async () => {
|
tap.test('Edge Case - Empty Routes Array', async () => {
|
||||||
// Attempting to find routes in an empty array
|
|
||||||
const emptyRoutes: IRouteConfig[] = [];
|
const emptyRoutes: IRouteConfig[] = [];
|
||||||
const matches = findMatchingRoutes(emptyRoutes, { domain: 'example.com', port: 80 });
|
const matches = findMatchingRoutes(emptyRoutes, { domain: 'example.com', port: 80 });
|
||||||
|
|
||||||
@@ -245,82 +265,98 @@ tap.test('Edge Case - Empty Routes Array', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Edge Case - Multiple Matching Routes with Same Priority', async () => {
|
tap.test('Edge Case - Multiple Matching Routes with Same Priority', async () => {
|
||||||
// Create multiple routes with identical priority but different targets
|
const route1: IRouteConfig = {
|
||||||
const route1 = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
match: { ports: 80, domains: 'example.com' },
|
||||||
const route2 = createHttpRoute('example.com', { host: 'server2', port: 3000 });
|
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||||
const route3 = createHttpRoute('example.com', { host: 'server3', 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'
|
||||||
|
};
|
||||||
|
|
||||||
// Set all to the same priority
|
|
||||||
route1.priority = 100;
|
route1.priority = 100;
|
||||||
route2.priority = 100;
|
route2.priority = 100;
|
||||||
route3.priority = 100;
|
route3.priority = 100;
|
||||||
|
|
||||||
const routes = [route1, route2, route3];
|
const routes = [route1, route2, route3];
|
||||||
|
|
||||||
// Find matching routes
|
|
||||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||||
|
|
||||||
// Should find all three routes
|
|
||||||
expect(matches.length).toEqual(3);
|
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 });
|
const bestMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||||
expect(bestMatch).not.toBeUndefined();
|
expect(bestMatch).not.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
|
tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
|
||||||
// Create routes with wildcard domains and path patterns
|
const wildcardApiRoute: IRouteConfig = {
|
||||||
const wildcardApiRoute = createApiRoute('*.example.com', '/api', { host: 'api-server', port: 3000 }, {
|
match: { ports: 443, domains: '*.example.com', path: '/api/*' },
|
||||||
useTls: true,
|
action: {
|
||||||
certificate: 'auto'
|
type: 'forward',
|
||||||
});
|
targets: [{ host: 'api-server', port: 3000 }],
|
||||||
|
tls: { mode: 'terminate', certificate: 'auto' }
|
||||||
|
},
|
||||||
|
priority: 100,
|
||||||
|
name: 'API Route for *.example.com'
|
||||||
|
};
|
||||||
|
|
||||||
const exactApiRoute = createApiRoute('api.example.com', '/api', { host: 'specific-api-server', port: 3001 }, {
|
const exactApiRoute: IRouteConfig = {
|
||||||
useTls: true,
|
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
|
||||||
certificate: 'auto',
|
action: {
|
||||||
priority: 200 // Higher priority
|
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];
|
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 });
|
const matches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
||||||
|
|
||||||
// Should match both routes
|
|
||||||
expect(matches.length).toEqual(2);
|
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 });
|
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
||||||
expect(bestMatch).not.toBeUndefined();
|
expect(bestMatch).not.toBeUndefined();
|
||||||
if (bestMatch) {
|
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 });
|
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
|
||||||
expect(otherMatches.length).toEqual(1);
|
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 () => {
|
tap.test('Edge Case - Disabled Routes', async () => {
|
||||||
// Create enabled and disabled routes
|
const enabledRoute: IRouteConfig = {
|
||||||
const enabledRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
match: { ports: 80, domains: 'example.com' },
|
||||||
const disabledRoute = createHttpRoute('example.com', { host: 'server2', port: 3001 });
|
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;
|
disabledRoute.enabled = false;
|
||||||
|
|
||||||
const routes = [enabledRoute, disabledRoute];
|
const routes = [enabledRoute, disabledRoute];
|
||||||
|
|
||||||
// Find matching routes
|
|
||||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||||
|
|
||||||
// Should only find the enabled route
|
|
||||||
expect(matches.length).toEqual(1);
|
expect(matches.length).toEqual(1);
|
||||||
expect(matches[0].action.targets[0].port).toEqual(3000);
|
expect(matches[0].action.targets[0].port).toEqual(3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||||
// Create route with complex path and headers matching
|
|
||||||
const complexRoute: IRouteConfig = {
|
const complexRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
domains: 'api.example.com',
|
domains: 'api.example.com',
|
||||||
@@ -345,7 +381,6 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
|||||||
name: 'Complex API Route'
|
name: 'Complex API Route'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test with matching criteria
|
|
||||||
const matchingPath = routeMatchesPath(complexRoute, '/api/v2/users');
|
const matchingPath = routeMatchesPath(complexRoute, '/api/v2/users');
|
||||||
expect(matchingPath).toBeTrue();
|
expect(matchingPath).toBeTrue();
|
||||||
|
|
||||||
@@ -356,7 +391,6 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
|||||||
});
|
});
|
||||||
expect(matchingHeaders).toBeTrue();
|
expect(matchingHeaders).toBeTrue();
|
||||||
|
|
||||||
// Test with non-matching criteria
|
|
||||||
const nonMatchingPath = routeMatchesPath(complexRoute, '/api/v1/users');
|
const nonMatchingPath = routeMatchesPath(complexRoute, '/api/v1/users');
|
||||||
expect(nonMatchingPath).toBeFalse();
|
expect(nonMatchingPath).toBeFalse();
|
||||||
|
|
||||||
@@ -368,7 +402,6 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Edge Case - Port Range Matching', async () => {
|
tap.test('Edge Case - Port Range Matching', async () => {
|
||||||
// Create route with port range matching
|
|
||||||
const portRangeRoute: IRouteConfig = {
|
const portRangeRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
domains: 'example.com',
|
domains: 'example.com',
|
||||||
@@ -384,16 +417,13 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
|||||||
name: 'Port Range Route'
|
name: 'Port Range Route'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test with ports in the range
|
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
|
||||||
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue(); // Lower bound
|
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
|
||||||
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue(); // Middle
|
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue();
|
||||||
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue(); // Upper bound
|
|
||||||
|
|
||||||
// Test with ports outside the range
|
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse();
|
||||||
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse(); // Just below
|
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse();
|
||||||
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse(); // Just above
|
|
||||||
|
|
||||||
// Test with multiple port ranges
|
|
||||||
const multiRangeRoute: IRouteConfig = {
|
const multiRangeRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
domains: 'example.com',
|
domains: 'example.com',
|
||||||
@@ -420,55 +450,56 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
|||||||
// --------------------------------- Wildcard Domain Tests ---------------------------------
|
// --------------------------------- Wildcard Domain Tests ---------------------------------
|
||||||
|
|
||||||
tap.test('Wildcard Domain Handling', async () => {
|
tap.test('Wildcard Domain Handling', async () => {
|
||||||
// Create routes with different wildcard patterns
|
const simpleDomainRoute: IRouteConfig = {
|
||||||
const simpleDomainRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
match: { ports: 80, domains: 'example.com' },
|
||||||
const wildcardSubdomainRoute = createHttpRoute('*.example.com', { host: 'server2', port: 3001 });
|
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||||
const specificSubdomainRoute = createHttpRoute('api.example.com', { host: 'server3', port: 3002 });
|
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;
|
||||||
specificSubdomainRoute.priority = 200; // Highest priority for specific domain
|
wildcardSubdomainRoute.priority = 100;
|
||||||
wildcardSubdomainRoute.priority = 100; // Medium priority for wildcard
|
simpleDomainRoute.priority = 50;
|
||||||
simpleDomainRoute.priority = 50; // Lowest priority for generic domain
|
|
||||||
|
|
||||||
const routes = [simpleDomainRoute, wildcardSubdomainRoute, specificSubdomainRoute];
|
const routes = [simpleDomainRoute, wildcardSubdomainRoute, specificSubdomainRoute];
|
||||||
|
|
||||||
// Test exact domain match
|
|
||||||
expect(routeMatchesDomain(simpleDomainRoute, 'example.com')).toBeTrue();
|
expect(routeMatchesDomain(simpleDomainRoute, 'example.com')).toBeTrue();
|
||||||
expect(routeMatchesDomain(simpleDomainRoute, 'sub.example.com')).toBeFalse();
|
expect(routeMatchesDomain(simpleDomainRoute, 'sub.example.com')).toBeFalse();
|
||||||
|
|
||||||
// Test wildcard subdomain match
|
|
||||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'any.example.com')).toBeTrue();
|
expect(routeMatchesDomain(wildcardSubdomainRoute, 'any.example.com')).toBeTrue();
|
||||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'nested.sub.example.com')).toBeTrue();
|
expect(routeMatchesDomain(wildcardSubdomainRoute, 'nested.sub.example.com')).toBeTrue();
|
||||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'example.com')).toBeFalse();
|
expect(routeMatchesDomain(wildcardSubdomainRoute, 'example.com')).toBeFalse();
|
||||||
|
|
||||||
// Test specific subdomain match
|
|
||||||
expect(routeMatchesDomain(specificSubdomainRoute, 'api.example.com')).toBeTrue();
|
expect(routeMatchesDomain(specificSubdomainRoute, 'api.example.com')).toBeTrue();
|
||||||
expect(routeMatchesDomain(specificSubdomainRoute, 'other.example.com')).toBeFalse();
|
expect(routeMatchesDomain(specificSubdomainRoute, 'other.example.com')).toBeFalse();
|
||||||
expect(routeMatchesDomain(specificSubdomainRoute, 'sub.api.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 specificSubdomainRequest = { domain: 'api.example.com', port: 80 };
|
||||||
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
|
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
|
||||||
expect(bestSpecificMatch).not.toBeUndefined();
|
expect(bestSpecificMatch).not.toBeUndefined();
|
||||||
if (bestSpecificMatch) {
|
if (bestSpecificMatch) {
|
||||||
// Find which route was matched
|
|
||||||
const matchedPort = bestSpecificMatch.action.targets[0].port;
|
const matchedPort = bestSpecificMatch.action.targets[0].port;
|
||||||
console.log(`Matched route with port: ${matchedPort}`);
|
console.log(`Matched route with port: ${matchedPort}`);
|
||||||
|
|
||||||
// Verify it's the specific subdomain route (with highest priority)
|
|
||||||
expect(bestSpecificMatch.priority).toEqual(200);
|
expect(bestSpecificMatch.priority).toEqual(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test with a subdomain that matches wildcard but not specific
|
|
||||||
const otherSubdomainRequest = { domain: 'other.example.com', port: 80 };
|
const otherSubdomainRequest = { domain: 'other.example.com', port: 80 };
|
||||||
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
|
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
|
||||||
expect(bestWildcardMatch).not.toBeUndefined();
|
expect(bestWildcardMatch).not.toBeUndefined();
|
||||||
if (bestWildcardMatch) {
|
if (bestWildcardMatch) {
|
||||||
// Find which route was matched
|
|
||||||
const matchedPort = bestWildcardMatch.action.targets[0].port;
|
const matchedPort = bestWildcardMatch.action.targets[0].port;
|
||||||
console.log(`Matched route with port: ${matchedPort}`);
|
console.log(`Matched route with port: ${matchedPort}`);
|
||||||
|
|
||||||
// Verify it's the wildcard subdomain route (with medium priority)
|
|
||||||
expect(bestWildcardMatch.priority).toEqual(100);
|
expect(bestWildcardMatch.priority).toEqual(100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -476,39 +507,68 @@ tap.test('Wildcard Domain Handling', async () => {
|
|||||||
// --------------------------------- Integration Tests ---------------------------------
|
// --------------------------------- Integration Tests ---------------------------------
|
||||||
|
|
||||||
tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||||
// Create a comprehensive set of routes for a full application
|
|
||||||
const routes: IRouteConfig[] = [
|
const routes: IRouteConfig[] = [
|
||||||
// Main website with HTTPS and HTTP redirect
|
{
|
||||||
...createCompleteHttpsServer('example.com', { host: 'web-server', port: 8080 }, {
|
match: { ports: 443, domains: 'example.com' },
|
||||||
certificate: 'auto'
|
action: {
|
||||||
}),
|
type: 'forward',
|
||||||
|
targets: [{ host: 'web-server', port: 8080 }],
|
||||||
// API endpoints
|
tls: { mode: 'terminate', certificate: 'auto' }
|
||||||
createApiRoute('api.example.com', '/v1', { host: 'api-server', port: 3000 }, {
|
},
|
||||||
useTls: true,
|
name: 'HTTPS Terminate Route for example.com'
|
||||||
certificate: 'auto',
|
},
|
||||||
addCorsHeaders: true
|
{
|
||||||
}),
|
match: { ports: 80, domains: 'example.com' },
|
||||||
|
action: {
|
||||||
// WebSocket for real-time updates
|
type: 'socket-handler',
|
||||||
createWebSocketRoute('ws.example.com', '/live', { host: 'websocket-server', port: 5000 }, {
|
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||||
useTls: true,
|
},
|
||||||
certificate: 'auto'
|
name: 'HTTP to HTTPS Redirect for example.com'
|
||||||
}),
|
},
|
||||||
|
{
|
||||||
|
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||||
// Legacy system with passthrough
|
action: {
|
||||||
createHttpsPassthroughRoute('legacy.example.com', { host: 'legacy-server', port: 443 })
|
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);
|
const validationResult = validateRoutes(routes);
|
||||||
expect(validationResult.valid).toBeTrue();
|
expect(validationResult.valid).toBeTrue();
|
||||||
expect(validationResult.errors.length).toEqual(0);
|
expect(validationResult.errors.length).toEqual(0);
|
||||||
|
|
||||||
// Test route matching for different endpoints
|
|
||||||
|
|
||||||
// Web server (HTTPS)
|
|
||||||
const webServerMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
const webServerMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
||||||
expect(webServerMatch).not.toBeUndefined();
|
expect(webServerMatch).not.toBeUndefined();
|
||||||
if (webServerMatch) {
|
if (webServerMatch) {
|
||||||
@@ -516,14 +576,12 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
|||||||
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
|
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 });
|
const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||||
expect(webRedirectMatch).not.toBeUndefined();
|
expect(webRedirectMatch).not.toBeUndefined();
|
||||||
if (webRedirectMatch) {
|
if (webRedirectMatch) {
|
||||||
expect(webRedirectMatch.action.type).toEqual('socket-handler');
|
expect(webRedirectMatch.action.type).toEqual('socket-handler');
|
||||||
}
|
}
|
||||||
|
|
||||||
// API server
|
|
||||||
const apiMatch = findBestMatchingRoute(routes, {
|
const apiMatch = findBestMatchingRoute(routes, {
|
||||||
domain: 'api.example.com',
|
domain: 'api.example.com',
|
||||||
port: 443,
|
port: 443,
|
||||||
@@ -535,7 +593,6 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
|||||||
expect(apiMatch.action.targets[0].host).toEqual('api-server');
|
expect(apiMatch.action.targets[0].host).toEqual('api-server');
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket server
|
|
||||||
const wsMatch = findBestMatchingRoute(routes, {
|
const wsMatch = findBestMatchingRoute(routes, {
|
||||||
domain: 'ws.example.com',
|
domain: 'ws.example.com',
|
||||||
port: 443,
|
port: 443,
|
||||||
@@ -548,9 +605,6 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
|||||||
expect(wsMatch.action.websocket?.enabled).toBeTrue();
|
expect(wsMatch.action.websocket?.enabled).toBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets route was removed - static file serving should be handled externally
|
|
||||||
|
|
||||||
// Legacy system
|
|
||||||
const legacyMatch = findBestMatchingRoute(routes, {
|
const legacyMatch = findBestMatchingRoute(routes, {
|
||||||
domain: 'legacy.example.com',
|
domain: 'legacy.example.com',
|
||||||
port: 443
|
port: 443
|
||||||
@@ -565,7 +619,6 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
|||||||
// --------------------------------- Protocol Match Field Tests ---------------------------------
|
// --------------------------------- Protocol Match Field Tests ---------------------------------
|
||||||
|
|
||||||
tap.test('Routes: Should accept protocol field on route match', async () => {
|
tap.test('Routes: Should accept protocol field on route match', async () => {
|
||||||
// Create a route with protocol: 'http'
|
|
||||||
const httpOnlyRoute: IRouteConfig = {
|
const httpOnlyRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
ports: 443,
|
ports: 443,
|
||||||
@@ -583,16 +636,13 @@ tap.test('Routes: Should accept protocol field on route match', async () => {
|
|||||||
name: 'HTTP-only Route',
|
name: 'HTTP-only Route',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate the route - protocol field should not cause errors
|
|
||||||
const validation = validateRouteConfig(httpOnlyRoute);
|
const validation = validateRouteConfig(httpOnlyRoute);
|
||||||
expect(validation.valid).toBeTrue();
|
expect(validation.valid).toBeTrue();
|
||||||
|
|
||||||
// Verify the protocol field is preserved
|
|
||||||
expect(httpOnlyRoute.match.protocol).toEqual('http');
|
expect(httpOnlyRoute.match.protocol).toEqual('http');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Routes: Should accept protocol tcp on route match', async () => {
|
tap.test('Routes: Should accept protocol tcp on route match', async () => {
|
||||||
// Create a route with protocol: 'tcp'
|
|
||||||
const tcpOnlyRoute: IRouteConfig = {
|
const tcpOnlyRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
ports: 443,
|
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 () => {
|
tap.test('Routes: Protocol field should work with terminate-and-reencrypt', async () => {
|
||||||
// Create a terminate-and-reencrypt route that only accepts HTTP
|
const reencryptRoute: IRouteConfig = {
|
||||||
const reencryptRoute = createHttpsTerminateRoute(
|
match: { ports: 443, domains: 'secure.example.com' },
|
||||||
'secure.example.com',
|
action: {
|
||||||
{ host: 'backend', port: 443 },
|
type: 'forward',
|
||||||
{ reencrypt: true, certificate: 'auto', name: 'Reencrypt HTTP Route' }
|
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';
|
reencryptRoute.match.protocol = 'http';
|
||||||
|
|
||||||
// Validate the route
|
|
||||||
const validation = validateRouteConfig(reencryptRoute);
|
const validation = validateRouteConfig(reencryptRoute);
|
||||||
expect(validation.valid).toBeTrue();
|
expect(validation.valid).toBeTrue();
|
||||||
|
|
||||||
// Verify TLS mode
|
|
||||||
expect(reencryptRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
expect(reencryptRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||||
// Verify protocol field is preserved
|
|
||||||
expect(reencryptRoute.match.protocol).toEqual('http');
|
expect(reencryptRoute.match.protocol).toEqual('http');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Routes: Protocol field should not affect domain/port matching', async () => {
|
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 = {
|
const routeWithProtocol: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
ports: 443,
|
ports: 443,
|
||||||
@@ -669,11 +717,9 @@ tap.test('Routes: Protocol field should not affect domain/port matching', async
|
|||||||
|
|
||||||
const routes = [routeWithProtocol, routeWithoutProtocol];
|
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 });
|
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 443 });
|
||||||
expect(matches.length).toEqual(2);
|
expect(matches.length).toEqual(2);
|
||||||
|
|
||||||
// The one with higher priority should be first
|
|
||||||
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
||||||
expect(best).not.toBeUndefined();
|
expect(best).not.toBeUndefined();
|
||||||
expect(best!.name).toEqual('With Protocol');
|
expect(best!.name).toEqual('With Protocol');
|
||||||
@@ -696,11 +742,9 @@ tap.test('Routes: Protocol field preserved through route cloning', async () => {
|
|||||||
|
|
||||||
const cloned = cloneRoute(original);
|
const cloned = cloneRoute(original);
|
||||||
|
|
||||||
// Verify protocol is preserved in clone
|
|
||||||
expect(cloned.match.protocol).toEqual('http');
|
expect(cloned.match.protocol).toEqual('http');
|
||||||
expect(cloned.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
expect(cloned.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||||
|
|
||||||
// Modify clone should not affect original
|
|
||||||
cloned.match.protocol = 'tcp';
|
cloned.match.protocol = 'tcp';
|
||||||
expect(original.match.protocol).toEqual('http');
|
expect(original.match.protocol).toEqual('http');
|
||||||
});
|
});
|
||||||
@@ -720,7 +764,6 @@ tap.test('Routes: Protocol field preserved through route merging', async () => {
|
|||||||
name: 'Merge Base',
|
name: 'Merge Base',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge with override that changes name but not protocol
|
|
||||||
const merged = mergeRouteConfigs(base, { name: 'Merged Route' });
|
const merged = mergeRouteConfigs(base, { name: 'Merged Route' });
|
||||||
expect(merged.match.protocol).toEqual('http');
|
expect(merged.match.protocol).toEqual('http');
|
||||||
expect(merged.name).toEqual('Merged Route');
|
expect(merged.name).toEqual('Merged Route');
|
||||||
|
|||||||
+150
-405
@@ -1,21 +1,7 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as plugins from '../ts/plugins.js';
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
// Import from individual modules to avoid naming conflicts
|
|
||||||
import {
|
import {
|
||||||
// Route helpers
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createApiRoute,
|
|
||||||
createWebSocketRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute
|
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
|
||||||
|
|
||||||
import {
|
|
||||||
// Route validators
|
|
||||||
validateRouteConfig,
|
validateRouteConfig,
|
||||||
validateRoutes,
|
validateRoutes,
|
||||||
isValidDomain,
|
isValidDomain,
|
||||||
@@ -27,7 +13,6 @@ import {
|
|||||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
// Route utilities
|
|
||||||
mergeRouteConfigs,
|
mergeRouteConfigs,
|
||||||
findMatchingRoutes,
|
findMatchingRoutes,
|
||||||
findBestMatchingRoute,
|
findBestMatchingRoute,
|
||||||
@@ -39,16 +24,6 @@ import {
|
|||||||
cloneRoute
|
cloneRoute
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
|
} 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 {
|
import type {
|
||||||
IRouteConfig,
|
IRouteConfig,
|
||||||
IRouteMatch,
|
IRouteMatch,
|
||||||
@@ -179,7 +154,11 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
|||||||
|
|
||||||
tap.test('Route Validation - validateRouteConfig', async () => {
|
tap.test('Route Validation - validateRouteConfig', async () => {
|
||||||
// Valid route config
|
// 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);
|
const validResult = validateRouteConfig(validRoute);
|
||||||
expect(validResult.valid).toBeTrue();
|
expect(validResult.valid).toBeTrue();
|
||||||
expect(validResult.errors.length).toEqual(0);
|
expect(validResult.errors.length).toEqual(0);
|
||||||
@@ -203,7 +182,11 @@ tap.test('Route Validation - validateRouteConfig', async () => {
|
|||||||
tap.test('Route Validation - validateRoutes', async () => {
|
tap.test('Route Validation - validateRoutes', async () => {
|
||||||
// Create valid and invalid routes
|
// Create valid and invalid routes
|
||||||
const 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: {
|
match: {
|
||||||
domains: 'invalid..domain',
|
domains: 'invalid..domain',
|
||||||
@@ -217,7 +200,11 @@ tap.test('Route Validation - validateRoutes', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} as IRouteConfig,
|
} 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);
|
const result = validateRoutes(routes);
|
||||||
@@ -230,13 +217,13 @@ tap.test('Route Validation - validateRoutes', async () => {
|
|||||||
|
|
||||||
tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
||||||
// Forward action
|
// 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();
|
expect(hasRequiredPropertiesForAction(forwardRoute, 'forward')).toBeTrue();
|
||||||
|
|
||||||
// Socket handler action (redirect functionality)
|
|
||||||
const redirectRoute = createHttpToHttpsRedirect('example.com');
|
|
||||||
expect(hasRequiredPropertiesForAction(redirectRoute, 'socket-handler')).toBeTrue();
|
|
||||||
|
|
||||||
// Socket handler action
|
// Socket handler action
|
||||||
const socketRoute: IRouteConfig = {
|
const socketRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
@@ -269,7 +256,11 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
|||||||
|
|
||||||
tap.test('Route Validation - assertValidRoute', async () => {
|
tap.test('Route Validation - assertValidRoute', async () => {
|
||||||
// Valid route
|
// 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();
|
expect(() => assertValidRoute(validRoute)).not.toThrow();
|
||||||
|
|
||||||
// Invalid route
|
// Invalid route
|
||||||
@@ -290,7 +281,11 @@ tap.test('Route Validation - assertValidRoute', async () => {
|
|||||||
|
|
||||||
tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||||
// Base route
|
// 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
|
// Override with different name and port
|
||||||
const overrideRoute: Partial<IRouteConfig> = {
|
const overrideRoute: Partial<IRouteConfig> = {
|
||||||
@@ -345,13 +340,25 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
|||||||
|
|
||||||
tap.test('Route Matching - routeMatchesDomain', async () => {
|
tap.test('Route Matching - routeMatchesDomain', async () => {
|
||||||
// Create route with wildcard domain
|
// 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
|
// 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
|
// 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
|
// Test wildcard domain matching
|
||||||
expect(routeMatchesDomain(wildcardRoute, 'sub.example.com')).toBeTrue();
|
expect(routeMatchesDomain(wildcardRoute, 'sub.example.com')).toBeTrue();
|
||||||
@@ -374,7 +381,11 @@ tap.test('Route Matching - routeMatchesDomain', async () => {
|
|||||||
|
|
||||||
tap.test('Route Matching - routeMatchesPort', async () => {
|
tap.test('Route Matching - routeMatchesPort', async () => {
|
||||||
// Create routes with different port configurations
|
// 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 = {
|
const multiPortRoute: IRouteConfig = {
|
||||||
match: {
|
match: {
|
||||||
@@ -526,8 +537,37 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
|||||||
'X-Custom-Header': 'value'
|
'X-Custom-Header': 'value'
|
||||||
})).toBeFalse();
|
})).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
|
// 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, {
|
expect(routeMatchesHeaders(noHeaderRoute, {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
})).toBeTrue();
|
})).toBeTrue();
|
||||||
@@ -536,10 +576,26 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
|||||||
tap.test('Route Finding - findMatchingRoutes', async () => {
|
tap.test('Route Finding - findMatchingRoutes', async () => {
|
||||||
// Create multiple routes
|
// Create multiple routes
|
||||||
const routes: IRouteConfig[] = [
|
const routes: IRouteConfig[] = [
|
||||||
createHttpRoute('example.com', { host: 'localhost', port: 3000 }),
|
{
|
||||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 }),
|
match: { ports: 80, domains: 'example.com' },
|
||||||
createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3002 }),
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||||
createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3003 })
|
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
|
// Set priorities
|
||||||
@@ -566,10 +622,18 @@ tap.test('Route Finding - findMatchingRoutes', async () => {
|
|||||||
expect(wsMatches[0].name).toInclude('WebSocket Route');
|
expect(wsMatches[0].name).toInclude('WebSocket Route');
|
||||||
|
|
||||||
// Test finding multiple routes that match same criteria
|
// 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;
|
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.priority = 20;
|
||||||
route2.match.path = '/api';
|
route2.match.path = '/api';
|
||||||
|
|
||||||
@@ -581,7 +645,11 @@ tap.test('Route Finding - findMatchingRoutes', async () => {
|
|||||||
expect(multiMatches[1].priority).toEqual(10);
|
expect(multiMatches[1].priority).toEqual(10);
|
||||||
|
|
||||||
// Test disabled routes
|
// 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;
|
disabledRoute.enabled = false;
|
||||||
|
|
||||||
const enabledRoutes = findMatchingRoutes([disabledRoute], { domain: 'example.com', port: 80 });
|
const enabledRoutes = findMatchingRoutes([disabledRoute], { domain: 'example.com', port: 80 });
|
||||||
@@ -590,14 +658,26 @@ tap.test('Route Finding - findMatchingRoutes', async () => {
|
|||||||
|
|
||||||
tap.test('Route Finding - findBestMatchingRoute', async () => {
|
tap.test('Route Finding - findBestMatchingRoute', async () => {
|
||||||
// Create multiple routes with different priorities
|
// 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;
|
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.priority = 20;
|
||||||
route2.match.path = '/api';
|
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.priority = 30;
|
||||||
route3.match.path = '/api/users';
|
route3.match.path = '/api/users';
|
||||||
|
|
||||||
@@ -615,29 +695,42 @@ tap.test('Route Finding - findBestMatchingRoute', async () => {
|
|||||||
|
|
||||||
tap.test('Route Utilities - generateRouteId', async () => {
|
tap.test('Route Utilities - generateRouteId', async () => {
|
||||||
// Test ID generation for different route types
|
// 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);
|
const httpId = generateRouteId(httpRoute);
|
||||||
expect(httpId).toInclude('example-com');
|
expect(httpId).toInclude('example-com');
|
||||||
expect(httpId).toInclude('80');
|
expect(httpId).toInclude('80');
|
||||||
expect(httpId).toInclude('forward');
|
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);
|
const httpsId = generateRouteId(httpsRoute);
|
||||||
expect(httpsId).toInclude('secure-example-com');
|
expect(httpsId).toInclude('secure-example-com');
|
||||||
expect(httpsId).toInclude('443');
|
expect(httpsId).toInclude('443');
|
||||||
expect(httpsId).toInclude('forward');
|
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);
|
const multiDomainId = generateRouteId(multiDomainRoute);
|
||||||
expect(multiDomainId).toInclude('example-com-example-org');
|
expect(multiDomainId).toInclude('example-com-example-org');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Route Utilities - cloneRoute', async () => {
|
tap.test('Route Utilities - cloneRoute', async () => {
|
||||||
// Create a route and clone it
|
// Create a route and clone it
|
||||||
const originalRoute = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
|
const originalRoute: IRouteConfig = {
|
||||||
certificate: 'auto',
|
match: { ports: 443, domains: 'example.com' },
|
||||||
name: 'Original Route'
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||||
});
|
name: 'Original Route',
|
||||||
|
};
|
||||||
|
|
||||||
const clonedRoute = cloneRoute(originalRoute);
|
const clonedRoute = cloneRoute(originalRoute);
|
||||||
|
|
||||||
@@ -652,352 +745,4 @@ tap.test('Route Utilities - cloneRoute', async () => {
|
|||||||
expect(originalRoute.name).toEqual('Original Route');
|
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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '26.0.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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ export { tsclass };
|
|||||||
import * as smartcrypto from '@push.rocks/smartcrypto';
|
import * as smartcrypto from '@push.rocks/smartcrypto';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
|
||||||
|
import * as smartnftables from '@push.rocks/smartnftables';
|
||||||
import * as smartrust from '@push.rocks/smartrust';
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
smartcrypto,
|
smartcrypto,
|
||||||
smartlog,
|
smartlog,
|
||||||
smartlogDestinationLocal,
|
smartlogDestinationLocal,
|
||||||
|
smartnftables,
|
||||||
smartrust,
|
smartrust,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface IDatagramRelayMessage {
|
|||||||
* - TS→Rust: { type: "reply", sourceIp, sourcePort, destPort, payloadBase64 }
|
* - TS→Rust: { type: "reply", sourceIp, sourcePort, destPort, payloadBase64 }
|
||||||
*/
|
*/
|
||||||
export class DatagramHandlerServer {
|
export class DatagramHandlerServer {
|
||||||
|
private static readonly MAX_BUFFER_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
private server: plugins.net.Server | null = null;
|
private server: plugins.net.Server | null = null;
|
||||||
private connection: plugins.net.Socket | null = null;
|
private connection: plugins.net.Socket | null = null;
|
||||||
private socketPath: string;
|
private socketPath: string;
|
||||||
@@ -100,6 +102,11 @@ export class DatagramHandlerServer {
|
|||||||
|
|
||||||
socket.on('data', (chunk: Buffer) => {
|
socket.on('data', (chunk: Buffer) => {
|
||||||
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
|
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
|
||||||
|
if (this.readBuffer.length > DatagramHandlerServer.MAX_BUFFER_SIZE) {
|
||||||
|
logger.log('error', `DatagramHandlerServer: buffer exceeded ${DatagramHandlerServer.MAX_BUFFER_SIZE} bytes, resetting`);
|
||||||
|
this.readBuffer = Buffer.alloc(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.processFrames();
|
this.processFrames();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ export interface IThroughputHistoryPoint {
|
|||||||
/**
|
/**
|
||||||
* Main metrics interface with clean, grouped API
|
* Main metrics interface with clean, grouped API
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Protocol distribution for frontend (client→proxy) or backend (proxy→upstream).
|
||||||
|
* 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 {
|
export interface IMetrics {
|
||||||
// Connection metrics
|
// Connection metrics
|
||||||
connections: {
|
connections: {
|
||||||
@@ -40,6 +57,12 @@ export interface IMetrics {
|
|||||||
byRoute(): Map<string, number>;
|
byRoute(): Map<string, number>;
|
||||||
byIP(): Map<string, number>;
|
byIP(): Map<string, number>;
|
||||||
topIPs(limit?: number): Array<{ ip: string; count: 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)
|
// Throughput metrics (bytes per second)
|
||||||
@@ -72,6 +95,7 @@ export interface IMetrics {
|
|||||||
byBackend(): Map<string, IBackendMetrics>;
|
byBackend(): Map<string, IBackendMetrics>;
|
||||||
protocols(): Map<string, string>;
|
protocols(): Map<string, string>;
|
||||||
topByErrors(limit?: number): Array<{ backend: string; errors: number }>;
|
topByErrors(limit?: number): Array<{ backend: string; errors: number }>;
|
||||||
|
detectedProtocols(): IProtocolCacheEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// UDP metrics
|
// UDP metrics
|
||||||
@@ -113,6 +137,28 @@ export interface IMetricsConfig {
|
|||||||
prometheusPrefix: string; // Default: smartproxy_
|
prometheusPrefix: string; // Default: smartproxy_
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protocol cache entry from the Rust proxy's auto-detection cache.
|
||||||
|
* Shows which protocol (h1/h2/h3) is detected for each backend+domain pair,
|
||||||
|
* including failure suppression state with escalating cooldowns.
|
||||||
|
*/
|
||||||
|
export interface IProtocolCacheEntry {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
domain: string | null;
|
||||||
|
protocol: string;
|
||||||
|
h3Port: number | null;
|
||||||
|
ageSecs: number;
|
||||||
|
lastAccessedSecs: number;
|
||||||
|
lastProbedSecs: number;
|
||||||
|
h2Suppressed: boolean;
|
||||||
|
h3Suppressed: boolean;
|
||||||
|
h2CooldownRemainingSecs: number | null;
|
||||||
|
h3CooldownRemainingSecs: number | null;
|
||||||
|
h2ConsecutiveFailures: number | null;
|
||||||
|
h3ConsecutiveFailures: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-backend metrics
|
* Per-backend metrics
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -141,8 +141,10 @@ export interface IRouteAuthentication {
|
|||||||
* Security options for routes
|
* Security options for routes
|
||||||
*/
|
*/
|
||||||
export interface IRouteSecurity {
|
export interface IRouteSecurity {
|
||||||
// Access control lists
|
// Access control lists.
|
||||||
ipAllowList?: string[]; // IP addresses that are allowed to connect
|
// 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
|
ipBlockList?: string[]; // IP addresses that are blocked from connecting
|
||||||
|
|
||||||
// Connection limits
|
// Connection limits
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js';
|
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.
|
* Preprocesses routes before sending them to Rust.
|
||||||
@@ -24,7 +25,7 @@ export class RoutePreprocessor {
|
|||||||
* - Non-serializable fields are stripped
|
* - Non-serializable fields are stripped
|
||||||
* - Original routes are preserved in the local map for handler lookup
|
* - Original routes are preserved in the local map for handler lookup
|
||||||
*/
|
*/
|
||||||
public preprocessForRust(routes: IRouteConfig[]): IRouteConfig[] {
|
public preprocessForRust(routes: IRouteConfig[]): IRustRouteConfig[] {
|
||||||
this.originalRoutes.clear();
|
this.originalRoutes.clear();
|
||||||
return routes.map((route, index) => this.preprocessRoute(route, index));
|
return routes.map((route, index) => this.preprocessRoute(route, index));
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export class RoutePreprocessor {
|
|||||||
return new Map(this.originalRoutes);
|
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}`;
|
const routeKey = route.name || route.id || `route_${index}`;
|
||||||
|
|
||||||
// Check if this route needs TS-side handling
|
// Check if this route needs TS-side handling
|
||||||
@@ -57,7 +58,7 @@ export class RoutePreprocessor {
|
|||||||
// Create a clean copy for Rust
|
// Create a clean copy for Rust
|
||||||
const cleanRoute: IRouteConfig = {
|
const cleanRoute: IRouteConfig = {
|
||||||
...route,
|
...route,
|
||||||
action: this.cleanAction(route.action, routeKey, needsTsHandling),
|
action: this.cleanAction(route.action, needsTsHandling),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure we have a name for handler lookup
|
// Ensure we have a name for handler lookup
|
||||||
@@ -65,7 +66,7 @@ export class RoutePreprocessor {
|
|||||||
cleanRoute.name = routeKey;
|
cleanRoute.name = routeKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanRoute;
|
return serializeRouteForRust(cleanRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
private routeNeedsTsHandling(route: IRouteConfig): boolean {
|
private routeNeedsTsHandling(route: IRouteConfig): boolean {
|
||||||
@@ -91,15 +92,16 @@ export class RoutePreprocessor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanAction(action: IRouteAction, routeKey: string, needsTsHandling: boolean): IRouteAction {
|
private cleanAction(action: IRouteAction, needsTsHandling: boolean): IRouteAction {
|
||||||
const cleanAction: IRouteAction = { ...action };
|
let cleanAction: IRouteAction = { ...action };
|
||||||
|
|
||||||
if (needsTsHandling) {
|
if (needsTsHandling) {
|
||||||
// Convert to socket-handler type for Rust (Rust will relay back to TS)
|
// Convert to socket-handler type for Rust (Rust will relay back to TS)
|
||||||
cleanAction.type = 'socket-handler';
|
const { socketHandler: _socketHandler, datagramHandler: _datagramHandler, ...serializableAction } = cleanAction;
|
||||||
// Remove the JS handlers (not serializable)
|
cleanAction = {
|
||||||
delete (cleanAction as any).socketHandler;
|
...serializableAction,
|
||||||
delete (cleanAction as any).datagramHandler;
|
type: 'socket-handler',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean targets - replace functions with static values
|
// Clean targets - replace functions with static values
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IMetrics, IBackendMetrics, 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 { 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.
|
* 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 {
|
export class RustMetricsAdapter implements IMetrics {
|
||||||
private bridge: RustProxyBridge;
|
private bridge: RustProxyBridge;
|
||||||
private cache: any = null;
|
private cache: IRustMetricsSnapshot | null = null;
|
||||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private pollIntervalMs: number;
|
private pollIntervalMs: number;
|
||||||
|
|
||||||
@@ -65,8 +66,8 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byRoute: (): Map<string, number> => {
|
byRoute: (): Map<string, number> => {
|
||||||
const result = new Map<string, number>();
|
const result = new Map<string, number>();
|
||||||
if (this.cache?.routes) {
|
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, (rm as any).activeConnections ?? 0);
|
result.set(name, rm.activeConnections ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -74,8 +75,8 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byIP: (): Map<string, number> => {
|
byIP: (): Map<string, number> => {
|
||||||
const result = new Map<string, number>();
|
const result = new Map<string, number>();
|
||||||
if (this.cache?.ips) {
|
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, (im as any).activeConnections ?? 0);
|
result.set(ip, im.activeConnections ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -83,13 +84,76 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
|
topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
|
||||||
const result: Array<{ ip: string; count: number }> = [];
|
const result: Array<{ ip: string; count: number }> = [];
|
||||||
if (this.cache?.ips) {
|
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.push({ ip, count: (im as any).activeConnections ?? 0 });
|
result.push({ ip, count: im.activeConnections ?? 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.sort((a, b) => b.count - a.count);
|
result.sort((a, b) => b.count - a.count);
|
||||||
return result.slice(0, limit);
|
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 = {
|
public throughput = {
|
||||||
@@ -113,7 +177,7 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
},
|
},
|
||||||
history: (seconds: number): Array<IThroughputHistoryPoint> => {
|
history: (seconds: number): Array<IThroughputHistoryPoint> => {
|
||||||
if (!this.cache?.throughputHistory) return [];
|
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,
|
timestamp: p.timestampMs,
|
||||||
in: p.bytesIn,
|
in: p.bytesIn,
|
||||||
out: p.bytesOut,
|
out: p.bytesOut,
|
||||||
@@ -122,10 +186,10 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
||||||
const result = new Map<string, IThroughputData>();
|
const result = new Map<string, IThroughputData>();
|
||||||
if (this.cache?.routes) {
|
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, {
|
result.set(name, {
|
||||||
in: (rm as any).throughputInBytesPerSec ?? 0,
|
in: rm.throughputInBytesPerSec ?? 0,
|
||||||
out: (rm as any).throughputOutBytesPerSec ?? 0,
|
out: rm.throughputOutBytesPerSec ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,10 +198,10 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byIP: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
byIP: (_windowSeconds?: number): Map<string, IThroughputData> => {
|
||||||
const result = new Map<string, IThroughputData>();
|
const result = new Map<string, IThroughputData>();
|
||||||
if (this.cache?.ips) {
|
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, {
|
result.set(ip, {
|
||||||
in: (im as any).throughputInBytesPerSec ?? 0,
|
in: im.throughputInBytesPerSec ?? 0,
|
||||||
out: (im as any).throughputOutBytesPerSec ?? 0,
|
out: im.throughputOutBytesPerSec ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,23 +237,22 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
byBackend: (): Map<string, IBackendMetrics> => {
|
byBackend: (): Map<string, IBackendMetrics> => {
|
||||||
const result = new Map<string, IBackendMetrics>();
|
const result = new Map<string, IBackendMetrics>();
|
||||||
if (this.cache?.backends) {
|
if (this.cache?.backends) {
|
||||||
for (const [key, bm] of Object.entries(this.cache.backends)) {
|
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
|
||||||
const m = bm as any;
|
const totalTimeUs = bm.totalConnectTimeUs ?? 0;
|
||||||
const totalTimeUs = m.totalConnectTimeUs ?? 0;
|
const count = bm.connectCount ?? 0;
|
||||||
const count = m.connectCount ?? 0;
|
const poolHits = bm.poolHits ?? 0;
|
||||||
const poolHits = m.poolHits ?? 0;
|
const poolMisses = bm.poolMisses ?? 0;
|
||||||
const poolMisses = m.poolMisses ?? 0;
|
|
||||||
const poolTotal = poolHits + poolMisses;
|
const poolTotal = poolHits + poolMisses;
|
||||||
result.set(key, {
|
result.set(key, {
|
||||||
protocol: m.protocol ?? 'unknown',
|
protocol: bm.protocol ?? 'unknown',
|
||||||
activeConnections: m.activeConnections ?? 0,
|
activeConnections: bm.activeConnections ?? 0,
|
||||||
totalConnections: m.totalConnections ?? 0,
|
totalConnections: bm.totalConnections ?? 0,
|
||||||
connectErrors: m.connectErrors ?? 0,
|
connectErrors: bm.connectErrors ?? 0,
|
||||||
handshakeErrors: m.handshakeErrors ?? 0,
|
handshakeErrors: bm.handshakeErrors ?? 0,
|
||||||
requestErrors: m.requestErrors ?? 0,
|
requestErrors: bm.requestErrors ?? 0,
|
||||||
avgConnectTimeMs: count > 0 ? (totalTimeUs / count) / 1000 : 0,
|
avgConnectTimeMs: count > 0 ? (totalTimeUs / count) / 1000 : 0,
|
||||||
poolHitRate: poolTotal > 0 ? poolHits / poolTotal : 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> => {
|
protocols: (): Map<string, string> => {
|
||||||
const result = new Map<string, string>();
|
const result = new Map<string, string>();
|
||||||
if (this.cache?.backends) {
|
if (this.cache?.backends) {
|
||||||
for (const [key, bm] of Object.entries(this.cache.backends)) {
|
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
|
||||||
result.set(key, (bm as any).protocol ?? 'unknown');
|
result.set(key, bm.protocol ?? 'unknown');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -207,15 +270,17 @@ export class RustMetricsAdapter implements IMetrics {
|
|||||||
topByErrors: (limit: number = 10): Array<{ backend: string; errors: number }> => {
|
topByErrors: (limit: number = 10): Array<{ backend: string; errors: number }> => {
|
||||||
const result: Array<{ backend: string; errors: number }> = [];
|
const result: Array<{ backend: string; errors: number }> = [];
|
||||||
if (this.cache?.backends) {
|
if (this.cache?.backends) {
|
||||||
for (const [key, bm] of Object.entries(this.cache.backends)) {
|
for (const [key, bm] of Object.entries(this.cache.backends) as Array<[string, IRustBackendMetrics]>) {
|
||||||
const m = bm as any;
|
const errors = (bm.connectErrors ?? 0) + (bm.handshakeErrors ?? 0) + (bm.requestErrors ?? 0);
|
||||||
const errors = (m.connectErrors ?? 0) + (m.handshakeErrors ?? 0) + (m.requestErrors ?? 0);
|
|
||||||
if (errors > 0) result.push({ backend: key, errors });
|
if (errors > 0) result.push({ backend: key, errors });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.sort((a, b) => b.errors - a.errors);
|
result.sort((a, b) => b.errors - a.errors);
|
||||||
return result.slice(0, limit);
|
return result.slice(0, limit);
|
||||||
},
|
},
|
||||||
|
detectedProtocols: (): IProtocolCacheEntry[] => {
|
||||||
|
return this.cache?.detectedProtocols ?? [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
public udp = {
|
public udp = {
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { logger } from '../../core/utils/logger.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-safe command definitions for the Rust proxy IPC protocol.
|
||||||
*/
|
*/
|
||||||
type TSmartProxyCommands = {
|
type TSmartProxyCommands = {
|
||||||
start: { params: { config: any }; result: void };
|
start: { params: { config: IRustProxyOptions }; result: void };
|
||||||
stop: { params: Record<string, never>; result: void };
|
stop: { params: Record<string, never>; result: void };
|
||||||
updateRoutes: { params: { routes: IRouteConfig[] }; result: void };
|
updateRoutes: { params: { routes: IRustRouteConfig[] }; result: void };
|
||||||
getMetrics: { params: Record<string, never>; result: any };
|
getMetrics: { params: Record<string, never>; result: IRustMetricsSnapshot };
|
||||||
getStatistics: { params: Record<string, never>; result: any };
|
getStatistics: { params: Record<string, never>; result: IRustStatistics };
|
||||||
provisionCertificate: { params: { routeName: string }; result: void };
|
provisionCertificate: { params: { routeName: string }; result: void };
|
||||||
renewCertificate: { params: { routeName: string }; result: void };
|
renewCertificate: { params: { routeName: string }; result: void };
|
||||||
getCertificateStatus: { params: { routeName: string }; result: any };
|
getCertificateStatus: { params: { routeName: string }; result: IRustCertificateStatus | null };
|
||||||
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
|
getListeningPorts: { params: Record<string, never>; result: { ports: number[] } };
|
||||||
getNftablesStatus: { params: Record<string, never>; result: any };
|
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
|
||||||
setSocketHandlerRelay: { params: { socketPath: string }; result: void };
|
addListeningPort: { params: { port: number }; result: void };
|
||||||
addListeningPort: { params: { port: number }; result: void };
|
removeListeningPort: { params: { port: number }; result: void };
|
||||||
removeListeningPort: { params: { port: number }; result: void };
|
|
||||||
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
|
loadCertificate: { params: { domain: string; cert: string; key: string; ca?: string }; result: void };
|
||||||
setDatagramHandlerRelay: { params: { socketPath: string }; result: void };
|
setDatagramHandlerRelay: { params: { socketPath: string }; result: void };
|
||||||
};
|
};
|
||||||
@@ -122,7 +127,7 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|||||||
|
|
||||||
// --- Convenience methods for each management command ---
|
// --- 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 });
|
await this.bridge.sendCommand('start', { config });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,15 +135,15 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|||||||
await this.bridge.sendCommand('stop', {} as Record<string, never>);
|
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 });
|
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>);
|
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>);
|
return this.bridge.sendCommand('getStatistics', {} as Record<string, never>);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +155,7 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|||||||
await this.bridge.sendCommand('renewCertificate', { routeName });
|
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 });
|
return this.bridge.sendCommand('getCertificateStatus', { routeName });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,10 +164,6 @@ export class RustProxyBridge extends plugins.EventEmitter {
|
|||||||
return result?.ports ?? [];
|
return result?.ports ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getNftablesStatus(): Promise<any> {
|
|
||||||
return this.bridge.sendCommand('getNftablesStatus', {} as Record<string, never>);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
public async setSocketHandlerRelay(socketPath: string): Promise<void> {
|
||||||
await this.bridge.sendCommand('setSocketHandlerRelay', { socketPath });
|
await this.bridge.sendCommand('setSocketHandlerRelay', { socketPath });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
|||||||
// Route management
|
// Route management
|
||||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||||
import { RouteValidator } from './utils/route-validator.js';
|
import { RouteValidator } from './utils/route-validator.js';
|
||||||
|
import { buildRustProxyOptions } from './utils/rust-config.js';
|
||||||
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
|
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
|
||||||
import { Mutex } from './utils/mutex.js';
|
import { Mutex } from './utils/mutex.js';
|
||||||
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
|
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
|
||||||
@@ -19,12 +20,13 @@ import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
|
|||||||
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js';
|
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js';
|
||||||
import type { IRouteConfig } from './models/route-types.js';
|
import type { IRouteConfig } from './models/route-types.js';
|
||||||
import type { IMetrics } from './models/metrics-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.
|
* SmartProxy - Rust-backed proxy engine with TypeScript configuration API.
|
||||||
*
|
*
|
||||||
* All networking (TCP, TLS, HTTP reverse proxy, connection management, security,
|
* All networking (TCP, TLS, HTTP reverse proxy, connection management, security)
|
||||||
* NFTables) is handled by the Rust binary. TypeScript is only:
|
* is handled by the Rust binary. TypeScript is only:
|
||||||
* - The npm module interface (types, route helpers)
|
* - The npm module interface (types, route helpers)
|
||||||
* - The thin IPC wrapper (this class)
|
* - The thin IPC wrapper (this class)
|
||||||
* - Socket-handler callback relay (for JS-defined handlers)
|
* - Socket-handler callback relay (for JS-defined handlers)
|
||||||
@@ -39,6 +41,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
private socketHandlerServer: SocketHandlerServer | null = null;
|
private socketHandlerServer: SocketHandlerServer | null = null;
|
||||||
private datagramHandlerServer: DatagramHandlerServer | null = null;
|
private datagramHandlerServer: DatagramHandlerServer | null = null;
|
||||||
private metricsAdapter: RustMetricsAdapter;
|
private metricsAdapter: RustMetricsAdapter;
|
||||||
|
private nftablesManager: InstanceType<typeof plugins.smartnftables.SmartNftables> | null = null;
|
||||||
private routeUpdateLock: Mutex;
|
private routeUpdateLock: Mutex;
|
||||||
private stopping = false;
|
private stopping = false;
|
||||||
private certProvisionPromise: Promise<void> | null = null;
|
private certProvisionPromise: Promise<void> | null = null;
|
||||||
@@ -128,6 +131,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle unexpected exit (only emits error if not intentionally stopping)
|
// Handle unexpected exit (only emits error if not intentionally stopping)
|
||||||
|
this.bridge.removeAllListeners('exit');
|
||||||
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
||||||
if (this.stopping) return;
|
if (this.stopping) return;
|
||||||
logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
|
logger.log('error', `RustProxy exited unexpectedly (code=${code}, signal=${signal})`, { component: 'smart-proxy' });
|
||||||
@@ -210,6 +214,9 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply NFTables rules for routes using nftables forwarding engine
|
||||||
|
await this.applyNftablesRules(this.settings.routes);
|
||||||
|
|
||||||
// Start metrics polling BEFORE cert provisioning — the Rust engine is already
|
// Start metrics polling BEFORE cert provisioning — the Rust engine is already
|
||||||
// running and accepting connections, so metrics should be available immediately.
|
// running and accepting connections, so metrics should be available immediately.
|
||||||
// Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
|
// Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
|
||||||
@@ -237,6 +244,12 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
this.certProvisionPromise = null;
|
this.certProvisionPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up NFTables rules
|
||||||
|
if (this.nftablesManager) {
|
||||||
|
await this.nftablesManager.cleanup();
|
||||||
|
this.nftablesManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop metrics polling
|
// Stop metrics polling
|
||||||
this.metricsAdapter.stopPolling();
|
this.metricsAdapter.stopPolling();
|
||||||
|
|
||||||
@@ -318,6 +331,13 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
this.datagramHandlerServer = null;
|
this.datagramHandlerServer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update NFTables rules
|
||||||
|
if (this.nftablesManager) {
|
||||||
|
await this.nftablesManager.cleanup();
|
||||||
|
this.nftablesManager = null;
|
||||||
|
}
|
||||||
|
await this.applyNftablesRules(newRoutes);
|
||||||
|
|
||||||
// Update stored routes
|
// Update stored routes
|
||||||
this.settings.routes = newRoutes;
|
this.settings.routes = newRoutes;
|
||||||
|
|
||||||
@@ -347,7 +367,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Get certificate status for a route (async - calls Rust).
|
* 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);
|
return this.bridge.getCertificateStatus(routeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +381,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Get statistics (async - calls Rust).
|
* Get statistics (async - calls Rust).
|
||||||
*/
|
*/
|
||||||
public async getStatistics(): Promise<any> {
|
public async getStatistics(): Promise<IRustStatistics> {
|
||||||
return this.bridge.getStatistics();
|
return this.bridge.getStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,48 +430,64 @@ export class SmartProxy extends plugins.EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get NFTables status (async - calls Rust).
|
* Get NFTables status.
|
||||||
*/
|
*/
|
||||||
public async getNfTablesStatus(): Promise<Record<string, any>> {
|
public getNfTablesStatus(): plugins.smartnftables.INftStatus | null {
|
||||||
return this.bridge.getNftablesStatus();
|
return this.nftablesManager?.status() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Private helpers ---
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply NFTables rules for routes using the nftables forwarding engine.
|
||||||
|
*/
|
||||||
|
private async applyNftablesRules(routes: IRouteConfig[]): Promise<void> {
|
||||||
|
const nftRoutes = routes.filter(r => r.action.forwardingEngine === 'nftables');
|
||||||
|
if (nftRoutes.length === 0) return;
|
||||||
|
|
||||||
|
const tableName = nftRoutes.find(r => r.action.nftables?.tableName)?.action.nftables?.tableName ?? 'smartproxy';
|
||||||
|
const nft = new plugins.smartnftables.SmartNftables({ tableName });
|
||||||
|
await nft.initialize();
|
||||||
|
|
||||||
|
for (const route of nftRoutes) {
|
||||||
|
const routeId = route.name || 'unnamed';
|
||||||
|
const targets = route.action.targets;
|
||||||
|
if (!targets) continue;
|
||||||
|
|
||||||
|
const nftOpts = route.action.nftables;
|
||||||
|
const protocol: plugins.smartnftables.TNftProtocol = (nftOpts?.protocol as any) ?? 'tcp';
|
||||||
|
const preserveSourceIP = nftOpts?.preserveSourceIP ?? false;
|
||||||
|
|
||||||
|
const ports = Array.isArray(route.match.ports)
|
||||||
|
? route.match.ports.flatMap(p => typeof p === 'number' ? [p] : [])
|
||||||
|
: typeof route.match.ports === 'number' ? [route.match.ports] : [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const targetHost = Array.isArray(target.host) ? target.host[0] : target.host;
|
||||||
|
if (typeof targetHost !== 'string') continue;
|
||||||
|
|
||||||
|
for (const sourcePort of ports) {
|
||||||
|
const targetPort = typeof target.port === 'number' ? target.port : sourcePort;
|
||||||
|
await nft.nat.addPortForwarding(`${routeId}-${sourcePort}-${targetPort}`, {
|
||||||
|
sourcePort,
|
||||||
|
targetHost,
|
||||||
|
targetPort,
|
||||||
|
protocol,
|
||||||
|
preserveSourceIP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nftablesManager = nft;
|
||||||
|
logger.log('info', `Applied NFTables rules for ${nftRoutes.length} route(s)`, { component: 'smart-proxy' });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Rust configuration object from TS settings.
|
* Build the Rust configuration object from TS settings.
|
||||||
*/
|
*/
|
||||||
private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any {
|
private buildRustConfig(routes: IRustProxyOptions['routes'], acmeOverride?: IAcmeOptions): IRustProxyOptions {
|
||||||
const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme;
|
return buildRustProxyOptions(this.settings, routes, acmeOverride);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
* SmartProxy Route Utilities
|
* SmartProxy Route Utilities
|
||||||
*
|
*
|
||||||
* This file exports all route-related utilities for the SmartProxy module,
|
* 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 route validator (class-based and functional API)
|
||||||
export * from './route-validator.js';
|
export * from './route-validator.js';
|
||||||
|
|
||||||
@@ -20,10 +17,5 @@ export { generateDefaultCertificate } from './default-cert-generator.js';
|
|||||||
// Export concurrency semaphore
|
// Export concurrency semaphore
|
||||||
export { ConcurrencySemaphore } from './concurrency-semaphore.js';
|
export { ConcurrencySemaphore } from './concurrency-semaphore.js';
|
||||||
|
|
||||||
// Export additional functions from route-helpers that weren't already exported
|
// Export socket handlers
|
||||||
export {
|
export { SocketHandlers } from './socket-handlers.js';
|
||||||
createApiGatewayRoute,
|
|
||||||
addRateLimiting,
|
|
||||||
addBasicAuth,
|
|
||||||
addJwtAuth
|
|
||||||
} from './route-helpers.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,124 +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 {
|
|
||||||
return createPortMappingRoute({
|
|
||||||
sourcePortRange: options.ports,
|
|
||||||
targetHost: options.targetHost,
|
|
||||||
portMapper: (context) => context.port + options.offset,
|
|
||||||
name: options.name || `Offset Mapping (${options.offset > 0 ? '+' : ''}${options.offset}) for ${options.domains || 'all domains'}`,
|
|
||||||
domains: options.domains,
|
|
||||||
priority: options.priority,
|
|
||||||
...options
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -169,13 +169,27 @@ export function routeMatchesHeaders(
|
|||||||
return true; // No headers specified means it matches any headers
|
return true; // No headers specified means it matches any headers
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert RegExp patterns to strings for HeaderMatcher
|
for (const [headerName, expectedValue] of Object.entries(route.match.headers)) {
|
||||||
const stringHeaders: Record<string, string> = {};
|
const actualKey = Object.keys(headers).find((key) => key.toLowerCase() === headerName.toLowerCase());
|
||||||
for (const [key, value] of Object.entries(route.match.headers)) {
|
const actualValue = actualKey ? headers[actualKey] : undefined;
|
||||||
stringHeaders[key] = value instanceof RegExp ? value.source : value;
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -197,9 +197,18 @@ export class RouteValidator {
|
|||||||
if (route.security.ipAllowList) {
|
if (route.security.ipAllowList) {
|
||||||
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
|
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
|
||||||
|
|
||||||
for (const ip of allowList) {
|
for (const entry of allowList) {
|
||||||
if (!this.isValidIPPattern(ip)) {
|
if (typeof entry === 'string') {
|
||||||
errors.push(`Invalid IP pattern in allow list: ${ip}`);
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +267,9 @@ export class RouteValidator {
|
|||||||
errorMap.set(route.name, existingErrors);
|
errorMap.set(route.name, existingErrors);
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
routeNames.add(route.name);
|
if (route.name) {
|
||||||
|
routeNames.add(route.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate each route
|
// Validate each route
|
||||||
@@ -328,7 +339,7 @@ export class RouteValidator {
|
|||||||
if (catchAllRoutes.length > 1) {
|
if (catchAllRoutes.length > 1) {
|
||||||
for (const route of catchAllRoutes) {
|
for (const route of catchAllRoutes) {
|
||||||
conflicts.push({
|
conflicts.push({
|
||||||
route: route.name,
|
route: route.name || 'unnamed',
|
||||||
message: `Multiple catch-all routes on port ${port}`
|
message: `Multiple catch-all routes on port ${port}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-31
@@ -5,9 +5,9 @@
|
|||||||
* like echoing, proxying, HTTP responses, and redirects.
|
* like echoing, proxying, HTTP responses, and redirects.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from '../../../../plugins.js';
|
import * as plugins from '../../../plugins.js';
|
||||||
import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js';
|
import type { IRouteContext } from '../models/route-types.js';
|
||||||
import { createSocketTracker } from '../../../../core/utils/socket-tracker.js';
|
import { createSocketTracker } from '../../../core/utils/socket-tracker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal HTTP request parser for socket handlers.
|
* 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
-5
@@ -1,14 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {}
|
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist_*/**/*.d.ts"
|
"dist_*/**/*.d.ts"
|
||||||
|
|||||||
Reference in New Issue
Block a user