Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5be93c8d38 | |||
| 788ccea81e |
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "26.3.0",
|
||||
"version": "27.0.0",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
||||
@@ -462,35 +462,57 @@ For TLS termination modes (`terminate` and `terminate-and-reencrypt`), SmartProx
|
||||
|
||||
**HTTP to HTTPS Redirect**:
|
||||
```typescript
|
||||
import { createHttpToHttpsRedirect } from '@push.rocks/smartproxy';
|
||||
import { SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
const redirectRoute = createHttpToHttpsRedirect(['example.com', 'www.example.com']);
|
||||
const redirectRoute = {
|
||||
name: 'http-to-https',
|
||||
match: { ports: 80, domains: ['example.com', 'www.example.com'] },
|
||||
action: {
|
||||
type: 'socket-handler' as const,
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Complete HTTPS Server (with redirect)**:
|
||||
```typescript
|
||||
import { createCompleteHttpsServer } from '@push.rocks/smartproxy';
|
||||
|
||||
const routes = createCompleteHttpsServer(
|
||||
'example.com',
|
||||
{ host: 'localhost', port: 8080 },
|
||||
{ certificate: 'auto' }
|
||||
);
|
||||
const routes = [
|
||||
{
|
||||
name: 'https-server',
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate' as const, certificate: 'auto' as const }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'http-redirect',
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler' as const,
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
**Load Balancer with Health Checks**:
|
||||
```typescript
|
||||
import { createLoadBalancerRoute } from '@push.rocks/smartproxy';
|
||||
|
||||
const lbRoute = createLoadBalancerRoute(
|
||||
'api.example.com',
|
||||
[
|
||||
{ host: 'backend1', port: 8080 },
|
||||
{ host: 'backend2', port: 8080 },
|
||||
{ host: 'backend3', port: 8080 }
|
||||
],
|
||||
{ tls: { mode: 'terminate', certificate: 'auto' } }
|
||||
);
|
||||
const lbRoute = {
|
||||
name: 'load-balancer',
|
||||
match: { ports: 443, domains: 'api.example.com' },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [
|
||||
{ host: 'backend1', port: 8080 },
|
||||
{ host: 'backend2', port: 8080 },
|
||||
{ host: 'backend3', port: 8080 }
|
||||
],
|
||||
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
|
||||
loadBalancing: { algorithm: 'round-robin' as const }
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Smart SNI Requirement (v22.3+)
|
||||
|
||||
286
readme.md
286
readme.md
@@ -44,7 +44,7 @@ Whether you're building microservices, deploying edge infrastructure, proxying U
|
||||
Get up and running in 30 seconds:
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createCompleteHttpsServer } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
// Create a proxy with automatic HTTPS
|
||||
const proxy = new SmartProxy({
|
||||
@@ -53,13 +53,25 @@ const proxy = new SmartProxy({
|
||||
useProduction: true
|
||||
},
|
||||
routes: [
|
||||
// Complete HTTPS setup in one call! ✨
|
||||
...createCompleteHttpsServer('app.example.com', {
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}, {
|
||||
certificate: 'auto' // Automatic Let's Encrypt cert 🎩
|
||||
})
|
||||
// HTTPS route with automatic Let's Encrypt cert
|
||||
{
|
||||
name: 'https-app',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
},
|
||||
// HTTP → HTTPS redirect
|
||||
{
|
||||
name: 'http-redirect',
|
||||
match: { ports: 80, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -111,31 +123,38 @@ SmartProxy supports three TLS handling modes:
|
||||
### 🌐 HTTP to HTTPS Redirect
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createHttpToHttpsRedirect } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createHttpToHttpsRedirect(['example.com', '*.example.com'])
|
||||
]
|
||||
routes: [{
|
||||
name: 'http-to-https',
|
||||
match: { ports: 80, domains: ['example.com', '*.example.com'] },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### ⚖️ Load Balancer with Health Checks
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createLoadBalancerRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createLoadBalancerRoute(
|
||||
'app.example.com',
|
||||
[
|
||||
routes: [{
|
||||
name: 'load-balancer',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{ host: 'server1.internal', port: 8080 },
|
||||
{ host: 'server2.internal', port: 8080 },
|
||||
{ host: 'server3.internal', port: 8080 }
|
||||
],
|
||||
{
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
loadBalancing: {
|
||||
algorithm: 'round-robin',
|
||||
healthCheck: {
|
||||
path: '/health',
|
||||
@@ -145,57 +164,68 @@ const proxy = new SmartProxy({
|
||||
healthyThreshold: 2
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### 🔌 WebSocket Proxy
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createWebSocketRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createWebSocketRoute(
|
||||
'ws.example.com',
|
||||
{ host: 'websocket-server', port: 8080 },
|
||||
{
|
||||
path: '/socket',
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
routes: [{
|
||||
name: 'websocket',
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||
priority: 100,
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'websocket-server', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: {
|
||||
enabled: true,
|
||||
pingInterval: 30000,
|
||||
pingTimeout: 10000
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
### 🚦 API Gateway with Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createApiGatewayRoute, addRateLimiting } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
let apiRoute = createApiGatewayRoute(
|
||||
'api.example.com',
|
||||
'/api',
|
||||
{ host: 'api-backend', port: 8080 },
|
||||
{
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true
|
||||
}
|
||||
);
|
||||
|
||||
// Add rate limiting — 100 requests per minute per IP
|
||||
apiRoute = addRateLimiting(apiRoute, {
|
||||
maxRequests: 100,
|
||||
window: 60,
|
||||
keyBy: 'ip'
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'api-gateway',
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
|
||||
priority: 100,
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'api-backend', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
headers: {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
},
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 100,
|
||||
window: 60,
|
||||
keyBy: 'ip'
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
const proxy = new SmartProxy({ routes: [apiRoute] });
|
||||
```
|
||||
|
||||
### 🎮 Custom Protocol Handler (TCP)
|
||||
@@ -203,36 +233,40 @@ const proxy = new SmartProxy({ routes: [apiRoute] });
|
||||
SmartProxy lets you implement any protocol with full socket control. Routes with JavaScript socket handlers are automatically relayed from the Rust engine back to your TypeScript code:
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createSocketHandlerRoute, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
|
||||
|
||||
// Use pre-built handlers
|
||||
const echoRoute = createSocketHandlerRoute(
|
||||
'echo.example.com',
|
||||
7777,
|
||||
SocketHandlers.echo
|
||||
);
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
// Use pre-built handlers
|
||||
{
|
||||
name: 'echo-server',
|
||||
match: { ports: 7777, domains: 'echo.example.com' },
|
||||
action: { type: 'socket-handler', socketHandler: SocketHandlers.echo }
|
||||
},
|
||||
// Or create your own custom protocol
|
||||
{
|
||||
name: 'custom-protocol',
|
||||
match: { ports: 9999, domains: 'custom.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: async (socket) => {
|
||||
console.log(`New connection on custom protocol`);
|
||||
socket.write('Welcome to my custom protocol!\n');
|
||||
|
||||
// Or create your own custom protocol
|
||||
const customRoute = createSocketHandlerRoute(
|
||||
'custom.example.com',
|
||||
9999,
|
||||
async (socket) => {
|
||||
console.log(`New connection on custom protocol`);
|
||||
socket.write('Welcome to my custom protocol!\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
switch (command) {
|
||||
case 'PING': socket.write('PONG\n'); break;
|
||||
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
|
||||
case 'QUIT': socket.end('Goodbye!\n'); break;
|
||||
default: socket.write(`Unknown: ${command}\n`);
|
||||
socket.on('data', (data) => {
|
||||
const command = data.toString().trim();
|
||||
switch (command) {
|
||||
case 'PING': socket.write('PONG\n'); break;
|
||||
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
|
||||
case 'QUIT': socket.end('Goodbye!\n'); break;
|
||||
default: socket.write(`Unknown: ${command}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const proxy = new SmartProxy({ routes: [echoRoute, customRoute] });
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
**Pre-built Socket Handlers:**
|
||||
@@ -387,20 +421,23 @@ const dualStackRoute: IRouteConfig = {
|
||||
For ultra-low latency on Linux, use kernel-level forwarding via [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables) (requires root):
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createNfTablesTerminateRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createNfTablesTerminateRoute(
|
||||
'fast.example.com',
|
||||
{ host: 'backend', port: 8080 },
|
||||
{
|
||||
ports: 443,
|
||||
certificate: 'auto',
|
||||
routes: [{
|
||||
name: 'nftables-fast',
|
||||
match: { ports: 443, domains: 'fast.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'backend', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
nftables: {
|
||||
protocol: 'tcp',
|
||||
preserveSourceIP: true // Backend sees real client IP
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
@@ -409,15 +446,18 @@ const proxy = new SmartProxy({
|
||||
Forward encrypted traffic to backends without terminating TLS — the proxy routes based on the SNI hostname alone:
|
||||
|
||||
```typescript
|
||||
import { SmartProxy, createHttpsPassthroughRoute } from '@push.rocks/smartproxy';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createHttpsPassthroughRoute('secure.example.com', {
|
||||
host: 'backend-that-handles-tls',
|
||||
port: 8443
|
||||
})
|
||||
]
|
||||
routes: [{
|
||||
name: 'sni-passthrough',
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'backend-that-handles-tls', port: 8443 }],
|
||||
tls: { mode: 'passthrough' }
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
@@ -524,15 +564,7 @@ Comprehensive per-route security options:
|
||||
}
|
||||
```
|
||||
|
||||
**Security modifier helpers** let you add security to any existing route:
|
||||
|
||||
```typescript
|
||||
import { addRateLimiting, addBasicAuth, addJwtAuth } from '@push.rocks/smartproxy';
|
||||
|
||||
let route = createHttpsTerminateRoute('api.example.com', { host: 'backend', port: 8080 });
|
||||
route = addRateLimiting(route, { maxRequests: 100, window: 60, keyBy: 'ip' });
|
||||
route = addBasicAuth(route, { users: [{ username: 'admin', password: 'secret' }] });
|
||||
```
|
||||
Security options are configured directly on each route's `security` property — no separate helpers needed.
|
||||
|
||||
### 📊 Runtime Management
|
||||
|
||||
@@ -712,7 +744,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
|
||||
```
|
||||
|
||||
- **Rust Engine** handles all networking: TCP, UDP, TLS, QUIC, HTTP proxying, connection management, security, and metrics
|
||||
- **TypeScript** provides the npm API, configuration types, route helpers, validation, and handler callbacks
|
||||
- **TypeScript** provides the npm API, configuration types, validation, and handler callbacks
|
||||
- **NFTables** managed by [`@push.rocks/smartnftables`](https://code.foss.global/push.rocks/smartnftables) — kernel-level DNAT/SNAT forwarding, firewall rules, and rate limiting via the `nft` CLI
|
||||
- **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary
|
||||
- **Socket/Datagram Relay** — Unix domain socket servers for routes requiring TypeScript-side handling (socket handlers, datagram handlers, dynamic host/port functions)
|
||||
@@ -858,47 +890,13 @@ interface IRouteQuic {
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Helper Functions Reference
|
||||
|
||||
All helpers are fully typed and return `IRouteConfig` or `IRouteConfig[]`:
|
||||
## 🛠️ Exports Reference
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// HTTP/HTTPS
|
||||
createHttpRoute, // Plain HTTP route
|
||||
createHttpsTerminateRoute, // HTTPS with TLS termination
|
||||
createHttpsPassthroughRoute, // SNI passthrough (no termination)
|
||||
createHttpToHttpsRedirect, // HTTP → HTTPS redirect
|
||||
createCompleteHttpsServer, // HTTPS + redirect combo (returns IRouteConfig[])
|
||||
|
||||
// Load Balancing
|
||||
createLoadBalancerRoute, // Multi-backend with health checks
|
||||
createSmartLoadBalancer, // Dynamic domain-based backend selection
|
||||
|
||||
// API & WebSocket
|
||||
createApiRoute, // API route with path matching
|
||||
createApiGatewayRoute, // API gateway with CORS
|
||||
createWebSocketRoute, // WebSocket-enabled route
|
||||
|
||||
// Custom Protocols
|
||||
createSocketHandlerRoute, // Custom TCP socket handler
|
||||
SocketHandlers, // Pre-built handlers (echo, proxy, block, etc.)
|
||||
|
||||
// NFTables (Linux, requires root)
|
||||
createNfTablesRoute, // Kernel-level packet forwarding
|
||||
createNfTablesTerminateRoute, // NFTables + TLS termination
|
||||
createCompleteNfTablesHttpsServer, // NFTables HTTPS + redirect combo
|
||||
|
||||
// Dynamic Routing
|
||||
createPortMappingRoute, // Port mapping with context
|
||||
createOffsetPortMappingRoute, // Simple port offset
|
||||
createDynamicRoute, // Dynamic host/port via functions
|
||||
createPortOffset, // Port offset factory
|
||||
|
||||
// Security Modifiers
|
||||
addRateLimiting, // Add rate limiting to any route
|
||||
addBasicAuth, // Add basic auth to any route
|
||||
addJwtAuth, // Add JWT auth to any route
|
||||
// Core
|
||||
SmartProxy, // Main proxy class
|
||||
SocketHandlers, // Pre-built socket handlers (echo, proxy, block, httpRedirect, httpServer, etc.)
|
||||
|
||||
// Route Utilities
|
||||
mergeRouteConfigs, // Deep-merge two route configs
|
||||
@@ -910,7 +908,7 @@ import {
|
||||
} from '@push.rocks/smartproxy';
|
||||
```
|
||||
|
||||
> **Tip:** For UDP datagram handler routes or QUIC/HTTP3 routes, construct `IRouteConfig` objects directly — there are no helper functions for these yet. See the [UDP Datagram Handler](#-udp-datagram-handler) and [QUIC / HTTP3 Forwarding](#-quic--http3-forwarding) examples above.
|
||||
All routes are configured as plain `IRouteConfig` objects with `match` and `action` properties — see the examples throughout this document.
|
||||
|
||||
## 📖 API Documentation
|
||||
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
use crate::route_types::*;
|
||||
use crate::tls_types::*;
|
||||
|
||||
/// Create a simple HTTP forwarding route.
|
||||
/// Equivalent to SmartProxy's `createHttpRoute()`.
|
||||
pub fn create_http_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(domains.into()),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(vec![RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(target_host.into()),
|
||||
port: PortSpec::Fixed(target_port),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
}]),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: None,
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an HTTPS termination route.
|
||||
/// Equivalent to SmartProxy's `createHttpsTerminateRoute()`.
|
||||
pub fn create_https_terminate_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
let mut route = create_http_route(domains, target_host, target_port);
|
||||
route.route_match.ports = PortRange::Single(443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Terminate,
|
||||
certificate: Some(CertificateSpec::Auto("auto".to_string())),
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
/// Create a TLS passthrough route.
|
||||
/// Equivalent to SmartProxy's `createHttpsPassthroughRoute()`.
|
||||
pub fn create_https_passthrough_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> RouteConfig {
|
||||
let mut route = create_http_route(domains, target_host, target_port);
|
||||
route.route_match.ports = PortRange::Single(443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None,
|
||||
acme: None,
|
||||
versions: None,
|
||||
ciphers: None,
|
||||
honor_cipher_order: None,
|
||||
session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
/// Create an HTTP-to-HTTPS redirect route.
|
||||
/// Equivalent to SmartProxy's `createHttpToHttpsRedirect()`.
|
||||
pub fn create_http_to_https_redirect(
|
||||
domains: impl Into<DomainSpec>,
|
||||
) -> RouteConfig {
|
||||
let domains = domains.into();
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(domains),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: None,
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: Some(RouteAdvanced {
|
||||
timeout: None,
|
||||
headers: None,
|
||||
keep_alive: None,
|
||||
static_files: None,
|
||||
test_response: Some(RouteTestResponse {
|
||||
status: 301,
|
||||
headers: {
|
||||
let mut h = std::collections::HashMap::new();
|
||||
h.insert("Location".to_string(), "https://{domain}{path}".to_string());
|
||||
h
|
||||
},
|
||||
body: String::new(),
|
||||
}),
|
||||
url_rewrite: None,
|
||||
}),
|
||||
options: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: Some("HTTP to HTTPS Redirect".to_string()),
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a complete HTTPS server with HTTP redirect.
|
||||
/// Equivalent to SmartProxy's `createCompleteHttpsServer()`.
|
||||
pub fn create_complete_https_server(
|
||||
domain: impl Into<String>,
|
||||
target_host: impl Into<String>,
|
||||
target_port: u16,
|
||||
) -> Vec<RouteConfig> {
|
||||
let domain = domain.into();
|
||||
let target_host = target_host.into();
|
||||
|
||||
vec![
|
||||
create_http_to_https_redirect(DomainSpec::Single(domain.clone())),
|
||||
create_https_terminate_route(
|
||||
DomainSpec::Single(domain),
|
||||
target_host,
|
||||
target_port,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Create a load balancer route.
|
||||
/// Equivalent to SmartProxy's `createLoadBalancerRoute()`.
|
||||
pub fn create_load_balancer_route(
|
||||
domains: impl Into<DomainSpec>,
|
||||
targets: Vec<(String, u16)>,
|
||||
tls: Option<RouteTls>,
|
||||
) -> RouteConfig {
|
||||
let route_targets: Vec<RouteTarget> = targets
|
||||
.into_iter()
|
||||
.map(|(host, port)| RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(host),
|
||||
port: PortSpec::Fixed(port),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let port = if tls.is_some() { 443 } else { 80 };
|
||||
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(port),
|
||||
domains: Some(domains.into()),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(route_targets),
|
||||
tls,
|
||||
websocket: None,
|
||||
load_balancing: Some(RouteLoadBalancing {
|
||||
algorithm: LoadBalancingAlgorithm::RoundRobin,
|
||||
health_check: None,
|
||||
}),
|
||||
advanced: None,
|
||||
options: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: Some("Load Balancer".to_string()),
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience conversions for DomainSpec
|
||||
impl From<&str> for DomainSpec {
|
||||
fn from(s: &str) -> Self {
|
||||
DomainSpec::Single(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DomainSpec {
|
||||
fn from(s: String) -> Self {
|
||||
DomainSpec::Single(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for DomainSpec {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
DomainSpec::List(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<&str>> for DomainSpec {
|
||||
fn from(v: Vec<&str>) -> Self {
|
||||
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tls_types::TlsMode;
|
||||
|
||||
#[test]
|
||||
fn test_create_http_route() {
|
||||
let route = create_http_route("example.com", "localhost", 8080);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
let domains = route.route_match.domains.as_ref().unwrap().to_vec();
|
||||
assert_eq!(domains, vec!["example.com"]);
|
||||
let target = &route.action.targets.as_ref().unwrap()[0];
|
||||
assert_eq!(target.host.first(), "localhost");
|
||||
assert_eq!(target.port.resolve(80), 8080);
|
||||
assert!(route.action.tls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_https_terminate_route() {
|
||||
let route = create_https_terminate_route("api.example.com", "backend", 3000);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
|
||||
let tls = route.action.tls.as_ref().unwrap();
|
||||
assert_eq!(tls.mode, TlsMode::Terminate);
|
||||
assert!(tls.certificate.as_ref().unwrap().is_auto());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_https_passthrough_route() {
|
||||
let route = create_https_passthrough_route("secure.example.com", "backend", 443);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
|
||||
let tls = route.action.tls.as_ref().unwrap();
|
||||
assert_eq!(tls.mode, TlsMode::Passthrough);
|
||||
assert!(tls.certificate.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_http_to_https_redirect() {
|
||||
let route = create_http_to_https_redirect("example.com");
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
assert!(route.action.targets.is_none());
|
||||
let test_response = route.action.advanced.as_ref().unwrap().test_response.as_ref().unwrap();
|
||||
assert_eq!(test_response.status, 301);
|
||||
assert!(test_response.headers.contains_key("Location"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_complete_https_server() {
|
||||
let routes = create_complete_https_server("example.com", "backend", 8080);
|
||||
assert_eq!(routes.len(), 2);
|
||||
// First route is HTTP redirect
|
||||
assert_eq!(routes[0].route_match.ports.to_ports(), vec![80]);
|
||||
// Second route is HTTPS terminate
|
||||
assert_eq!(routes[1].route_match.ports.to_ports(), vec![443]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_load_balancer_route() {
|
||||
let targets = vec![
|
||||
("backend1".to_string(), 8080),
|
||||
("backend2".to_string(), 8080),
|
||||
("backend3".to_string(), 8080),
|
||||
];
|
||||
let route = create_load_balancer_route("*.example.com", targets, None);
|
||||
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
|
||||
assert_eq!(route.action.targets.as_ref().unwrap().len(), 3);
|
||||
let lb = route.action.load_balancing.as_ref().unwrap();
|
||||
assert_eq!(lb.algorithm, LoadBalancingAlgorithm::RoundRobin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_spec_from_str() {
|
||||
let spec: DomainSpec = "example.com".into();
|
||||
assert_eq!(spec.to_vec(), vec!["example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_spec_from_vec() {
|
||||
let spec: DomainSpec = vec!["a.com", "b.com"].into();
|
||||
assert_eq!(spec.to_vec(), vec!["a.com", "b.com"]);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ pub mod proxy_options;
|
||||
pub mod tls_types;
|
||||
pub mod security_types;
|
||||
pub mod validation;
|
||||
pub mod helpers;
|
||||
|
||||
// Re-export all primary types
|
||||
pub use route_types::*;
|
||||
@@ -16,4 +15,3 @@ pub use proxy_options::*;
|
||||
pub use tls_types::*;
|
||||
pub use security_types::*;
|
||||
pub use validation::*;
|
||||
pub use helpers::*;
|
||||
|
||||
@@ -331,12 +331,48 @@ impl RustProxyOptions {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::helpers::*;
|
||||
use crate::route_types::*;
|
||||
use crate::tls_types::*;
|
||||
|
||||
fn make_route(domain: &str, host: &str, port: u16, listen_port: u16) -> RouteConfig {
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(listen_port),
|
||||
domains: Some(DomainSpec::Single(domain.to_string())),
|
||||
path: None, client_ip: None, transport: None, tls_version: None, headers: None, protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(vec![RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single(host.to_string()),
|
||||
port: PortSpec::Fixed(port),
|
||||
tls: None, websocket: None, load_balancing: None, send_proxy_protocol: None,
|
||||
headers: None, advanced: None, backend_transport: None, priority: None,
|
||||
}]),
|
||||
tls: None, websocket: None, load_balancing: None, advanced: None,
|
||||
options: None, send_proxy_protocol: None, udp: None,
|
||||
},
|
||||
headers: None, security: None, name: None, description: None,
|
||||
priority: None, tags: None, enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_passthrough_route(domain: &str, host: &str, port: u16) -> RouteConfig {
|
||||
let mut route = make_route(domain, host, port, 443);
|
||||
route.action.tls = Some(RouteTls {
|
||||
mode: TlsMode::Passthrough,
|
||||
certificate: None, acme: None, versions: None, ciphers: None,
|
||||
honor_cipher_order: None, session_timeout: None,
|
||||
});
|
||||
route
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_minimal() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![create_http_route("example.com", "localhost", 8080)],
|
||||
routes: vec![make_route("example.com", "localhost", 8080, 80)],
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&options).unwrap();
|
||||
@@ -348,8 +384,8 @@ mod tests {
|
||||
fn test_serde_roundtrip_full() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![
|
||||
create_http_route("a.com", "backend1", 8080),
|
||||
create_https_passthrough_route("b.com", "backend2", 443),
|
||||
make_route("a.com", "backend1", 8080, 80),
|
||||
make_passthrough_route("b.com", "backend2", 443),
|
||||
],
|
||||
connection_timeout: Some(5000),
|
||||
socket_timeout: Some(60000),
|
||||
@@ -402,9 +438,9 @@ mod tests {
|
||||
fn test_all_listening_ports() {
|
||||
let options = RustProxyOptions {
|
||||
routes: vec![
|
||||
create_http_route("a.com", "backend", 8080), // port 80
|
||||
create_https_passthrough_route("b.com", "backend", 443), // port 443
|
||||
create_http_route("c.com", "backend", 9090), // port 80 (duplicate)
|
||||
make_route("a.com", "backend", 8080, 80), // port 80
|
||||
make_passthrough_route("b.com", "backend", 443), // port 443
|
||||
make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -79,6 +79,31 @@ impl DomainSpec {
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience conversions for DomainSpec
|
||||
impl From<&str> for DomainSpec {
|
||||
fn from(s: &str) -> Self {
|
||||
DomainSpec::Single(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DomainSpec {
|
||||
fn from(s: String) -> Self {
|
||||
DomainSpec::Single(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for DomainSpec {
|
||||
fn from(v: Vec<String>) -> Self {
|
||||
DomainSpec::List(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<&str>> for DomainSpec {
|
||||
fn from(v: Vec<&str>) -> Self {
|
||||
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Header match value: either exact string or regex pattern.
|
||||
/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -104,7 +104,49 @@ mod tests {
|
||||
use crate::route_types::*;
|
||||
|
||||
fn make_valid_route() -> RouteConfig {
|
||||
crate::helpers::create_http_route("example.com", "localhost", 8080)
|
||||
RouteConfig {
|
||||
id: None,
|
||||
route_match: RouteMatch {
|
||||
ports: PortRange::Single(80),
|
||||
domains: Some(DomainSpec::Single("example.com".to_string())),
|
||||
path: None,
|
||||
client_ip: None,
|
||||
transport: None,
|
||||
tls_version: None,
|
||||
headers: None,
|
||||
protocol: None,
|
||||
},
|
||||
action: RouteAction {
|
||||
action_type: RouteActionType::Forward,
|
||||
targets: Some(vec![RouteTarget {
|
||||
target_match: None,
|
||||
host: HostSpec::Single("localhost".to_string()),
|
||||
port: PortSpec::Fixed(8080),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
send_proxy_protocol: None,
|
||||
headers: None,
|
||||
advanced: None,
|
||||
backend_transport: None,
|
||||
priority: None,
|
||||
}]),
|
||||
tls: None,
|
||||
websocket: None,
|
||||
load_balancing: None,
|
||||
advanced: None,
|
||||
options: None,
|
||||
send_proxy_protocol: None,
|
||||
udp: None,
|
||||
},
|
||||
headers: None,
|
||||
security: None,
|
||||
name: None,
|
||||
description: None,
|
||||
priority: None,
|
||||
tags: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,14 +7,40 @@
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use rustproxy::RustProxy;
|
||||
//! use rustproxy_config::{RustProxyOptions, create_https_passthrough_route};
|
||||
//! use rustproxy_config::*;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let options = RustProxyOptions {
|
||||
//! routes: vec![
|
||||
//! create_https_passthrough_route("example.com", "backend", 443),
|
||||
//! ],
|
||||
//! routes: vec![RouteConfig {
|
||||
//! id: None,
|
||||
//! route_match: RouteMatch {
|
||||
//! ports: PortRange::Single(443),
|
||||
//! domains: Some(DomainSpec::Single("example.com".to_string())),
|
||||
//! path: None, client_ip: None, transport: None,
|
||||
//! tls_version: None, headers: None, protocol: None,
|
||||
//! },
|
||||
//! action: RouteAction {
|
||||
//! action_type: RouteActionType::Forward,
|
||||
//! targets: Some(vec![RouteTarget {
|
||||
//! target_match: None,
|
||||
//! host: HostSpec::Single("backend".to_string()),
|
||||
//! port: PortSpec::Fixed(443),
|
||||
//! tls: None, websocket: None, load_balancing: None,
|
||||
//! send_proxy_protocol: None, headers: None, advanced: None,
|
||||
//! backend_transport: None, priority: None,
|
||||
//! }]),
|
||||
//! tls: Some(RouteTls {
|
||||
//! mode: TlsMode::Passthrough,
|
||||
//! certificate: None, acme: None, versions: None,
|
||||
//! ciphers: None, honor_cipher_order: None, session_timeout: None,
|
||||
//! }),
|
||||
//! websocket: None, load_balancing: None, advanced: None,
|
||||
//! options: None, send_proxy_protocol: None, udp: None,
|
||||
//! },
|
||||
//! headers: None, security: None, name: None, description: None,
|
||||
//! priority: None, tags: None, enabled: None,
|
||||
//! }],
|
||||
//! ..Default::default()
|
||||
//! };
|
||||
//!
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import {
|
||||
createHttpsTerminateRoute,
|
||||
createCompleteHttpsServer,
|
||||
createHttpRoute,
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import {
|
||||
mergeRouteConfigs,
|
||||
cloneRoute,
|
||||
@@ -19,8 +13,11 @@ import {
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
tap.test('route creation - createHttpsTerminateRoute produces correct structure', async () => {
|
||||
const route = createHttpsTerminateRoute('secure.example.com', { host: '127.0.0.1', port: 8443 });
|
||||
tap.test('route creation - HTTPS terminate route has correct structure', async () => {
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8443 }], tls: { mode: 'terminate', certificate: 'auto' } }
|
||||
};
|
||||
expect(route).toHaveProperty('match');
|
||||
expect(route).toHaveProperty('action');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
@@ -29,20 +26,10 @@ tap.test('route creation - createHttpsTerminateRoute produces correct structure'
|
||||
expect(route.match.domains).toEqual('secure.example.com');
|
||||
});
|
||||
|
||||
tap.test('route creation - createCompleteHttpsServer returns redirect and main route', async () => {
|
||||
const routes = createCompleteHttpsServer('app.example.com', { host: '127.0.0.1', port: 3000 });
|
||||
expect(routes).toBeArray();
|
||||
expect(routes.length).toBeGreaterThanOrEqual(2);
|
||||
// Should have an HTTP→HTTPS redirect and an HTTPS route
|
||||
const hasRedirect = routes.some((r) => r.action.type === 'forward' && r.action.redirect !== undefined);
|
||||
const hasHttps = routes.some((r) => r.action.tls?.mode === 'terminate');
|
||||
expect(hasRedirect || hasHttps).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('route validation - validateRoutes on a set of routes', async () => {
|
||||
const routes: IRouteConfig[] = [
|
||||
createHttpRoute('a.com', { host: '127.0.0.1', port: 3000 }),
|
||||
createHttpRoute('b.com', { host: '127.0.0.1', port: 4000 }),
|
||||
{ match: { ports: 80, domains: 'a.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
|
||||
{ match: { ports: 80, domains: 'b.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] } },
|
||||
];
|
||||
const result = validateRoutes(routes);
|
||||
expect(result.valid).toBeTrue();
|
||||
@@ -51,7 +38,7 @@ tap.test('route validation - validateRoutes on a set of routes', async () => {
|
||||
|
||||
tap.test('route validation - validateRoutes catches invalid route in set', async () => {
|
||||
const routes: any[] = [
|
||||
createHttpRoute('valid.com', { host: '127.0.0.1', port: 3000 }),
|
||||
{ match: { ports: 80, domains: 'valid.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
|
||||
{ match: { ports: 80 } }, // missing action
|
||||
];
|
||||
const result = validateRoutes(routes);
|
||||
@@ -60,23 +47,30 @@ tap.test('route validation - validateRoutes catches invalid route in set', async
|
||||
});
|
||||
|
||||
tap.test('path matching - routeMatchesPath with exact path', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
route.match.path = '/api';
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com', path: '/api' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesPath(route, '/api')).toBeTrue();
|
||||
expect(routeMatchesPath(route, '/other')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('path matching - route without path matches everything', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
// No path set, should match any path
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesPath(route, '/anything')).toBeTrue();
|
||||
expect(routeMatchesPath(route, '/')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
|
||||
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
base.priority = 10;
|
||||
base.name = 'base-route';
|
||||
const base: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
priority: 10,
|
||||
name: 'base-route'
|
||||
};
|
||||
|
||||
const merged = mergeRouteConfigs(base, {
|
||||
priority: 50,
|
||||
@@ -85,14 +79,16 @@ tap.test('route merging - mergeRouteConfigs combines routes', async () => {
|
||||
|
||||
expect(merged.priority).toEqual(50);
|
||||
expect(merged.name).toEqual('merged-route');
|
||||
// Original route fields should be preserved
|
||||
expect(merged.match.domains).toEqual('example.com');
|
||||
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
|
||||
});
|
||||
|
||||
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
|
||||
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
base.name = 'original';
|
||||
const base: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
name: 'original'
|
||||
};
|
||||
|
||||
const merged = mergeRouteConfigs(base, { name: 'changed' });
|
||||
expect(base.name).toEqual('original');
|
||||
@@ -100,20 +96,21 @@ tap.test('route merging - mergeRouteConfigs does not mutate original', async ()
|
||||
});
|
||||
|
||||
tap.test('route cloning - cloneRoute produces independent copy', async () => {
|
||||
const original = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
original.priority = 42;
|
||||
original.name = 'original-route';
|
||||
const original: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
priority: 42,
|
||||
name: 'original-route'
|
||||
};
|
||||
|
||||
const cloned = cloneRoute(original);
|
||||
|
||||
// Should be equal in value
|
||||
expect(cloned.match.domains).toEqual('example.com');
|
||||
expect(cloned.priority).toEqual(42);
|
||||
expect(cloned.name).toEqual('original-route');
|
||||
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
|
||||
expect(cloned.action.targets![0].port).toEqual(3000);
|
||||
|
||||
// Should be independent - modifying clone shouldn't affect original
|
||||
cloned.name = 'cloned-route';
|
||||
cloned.priority = 99;
|
||||
expect(original.name).toEqual('original-route');
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createLoadBalancerRoute,
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import {
|
||||
findMatchingRoutes,
|
||||
findBestMatchingRoute,
|
||||
@@ -22,24 +16,11 @@ import {
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
tap.test('route creation - createHttpRoute produces correct structure', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
expect(route).toHaveProperty('match');
|
||||
expect(route).toHaveProperty('action');
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.targets).toBeArray();
|
||||
expect(route.action.targets![0].host).toEqual('127.0.0.1');
|
||||
expect(route.action.targets![0].port).toEqual(3000);
|
||||
});
|
||||
|
||||
tap.test('route creation - createHttpRoute with array of domains', async () => {
|
||||
const route = createHttpRoute(['a.com', 'b.com'], { host: 'localhost', port: 8080 });
|
||||
expect(route.match.domains).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
tap.test('route validation - validateRouteConfig accepts valid route', async () => {
|
||||
const route = createHttpRoute('valid.example.com', { host: '10.0.0.1', port: 8080 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'valid.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 8080 }] }
|
||||
};
|
||||
const result = validateRouteConfig(route);
|
||||
expect(result.valid).toBeTrue();
|
||||
expect(result.errors).toHaveLength(0);
|
||||
@@ -67,30 +48,44 @@ tap.test('route validation - isValidPort checks correctly', async () => {
|
||||
});
|
||||
|
||||
tap.test('domain matching - exact domain', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesDomain(route, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('domain matching - wildcard domain', async () => {
|
||||
const route = createHttpRoute('*.example.com', { host: '127.0.0.1', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: '*.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesDomain(route, 'sub.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('port matching - single port', async () => {
|
||||
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
// createHttpRoute defaults to port 80
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
expect(routeMatchesPort(route, 80)).toBeTrue();
|
||||
expect(routeMatchesPort(route, 443)).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
|
||||
const lowPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
lowPriority.priority = 10;
|
||||
const lowPriority: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
priority: 10
|
||||
};
|
||||
|
||||
const highPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
|
||||
highPriority.priority = 100;
|
||||
const highPriority: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] },
|
||||
priority: 100
|
||||
};
|
||||
|
||||
const routes: IRouteConfig[] = [lowPriority, highPriority];
|
||||
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
@@ -100,9 +95,18 @@ tap.test('route finding - findBestMatchingRoute selects by priority', async () =
|
||||
});
|
||||
|
||||
tap.test('route finding - findMatchingRoutes returns all matches', async () => {
|
||||
const route1 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
|
||||
const route2 = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
|
||||
const route3 = createHttpRoute('other.com', { host: '127.0.0.1', port: 5000 });
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
|
||||
};
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] }
|
||||
};
|
||||
const route3: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'other.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 5000 }] }
|
||||
};
|
||||
|
||||
const matches = findMatchingRoutes([route1, route2, route3], { domain: 'example.com', port: 80 });
|
||||
expect(matches).toHaveLength(2);
|
||||
|
||||
@@ -2,146 +2,101 @@ import * as path from 'path';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createHttpsPassthroughRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createCompleteHttpsServer,
|
||||
createLoadBalancerRoute,
|
||||
createApiRoute,
|
||||
createWebSocketRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
// Test to demonstrate various route configurations using the new helpers
|
||||
tap.test('Route-based configuration examples', async (tools) => {
|
||||
// Example 1: HTTP-only configuration
|
||||
const httpOnlyRoute = createHttpRoute(
|
||||
'http.example.com',
|
||||
{
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
},
|
||||
{
|
||||
name: 'Basic HTTP Route'
|
||||
}
|
||||
);
|
||||
const httpOnlyRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'http.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'Basic HTTP Route'
|
||||
};
|
||||
|
||||
console.log('HTTP-only route created successfully:', httpOnlyRoute.name);
|
||||
expect(httpOnlyRoute.action.type).toEqual('forward');
|
||||
expect(httpOnlyRoute.match.domains).toEqual('http.example.com');
|
||||
|
||||
// Example 2: HTTPS Passthrough (SNI) configuration
|
||||
const httpsPassthroughRoute = createHttpsPassthroughRoute(
|
||||
'pass.example.com',
|
||||
{
|
||||
host: ['10.0.0.1', '10.0.0.2'], // Round-robin target IPs
|
||||
port: 443
|
||||
},
|
||||
{
|
||||
name: 'HTTPS Passthrough Route'
|
||||
}
|
||||
);
|
||||
const httpsPassthroughRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'pass.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 443 }, { host: '10.0.0.2', port: 443 }], tls: { mode: 'passthrough' } },
|
||||
name: 'HTTPS Passthrough Route'
|
||||
};
|
||||
|
||||
expect(httpsPassthroughRoute).toBeTruthy();
|
||||
expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough');
|
||||
expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue();
|
||||
|
||||
// Example 3: HTTPS Termination to HTTP Backend
|
||||
const terminateToHttpRoute = createHttpsTerminateRoute(
|
||||
'secure.example.com',
|
||||
{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
},
|
||||
{
|
||||
certificate: 'auto',
|
||||
name: 'HTTPS Termination to HTTP Backend'
|
||||
}
|
||||
);
|
||||
const terminateToHttpRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Termination to HTTP Backend'
|
||||
};
|
||||
|
||||
// Create the HTTP to HTTPS redirect for this domain
|
||||
const httpToHttpsRedirect = createHttpToHttpsRedirect(
|
||||
'secure.example.com',
|
||||
443,
|
||||
{
|
||||
name: 'HTTP to HTTPS Redirect for secure.example.com'
|
||||
}
|
||||
);
|
||||
const httpToHttpsRedirect: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'secure.example.com' },
|
||||
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
|
||||
name: 'HTTP to HTTPS Redirect for secure.example.com'
|
||||
};
|
||||
|
||||
expect(terminateToHttpRoute).toBeTruthy();
|
||||
expect(terminateToHttpRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(httpToHttpsRedirect.action.type).toEqual('socket-handler');
|
||||
|
||||
// Example 4: Load Balancer with HTTPS
|
||||
const loadBalancerRoute = createLoadBalancerRoute(
|
||||
'proxy.example.com',
|
||||
['internal-api-1.local', 'internal-api-2.local'],
|
||||
8443,
|
||||
{
|
||||
tls: {
|
||||
mode: 'terminate-and-reencrypt',
|
||||
certificate: 'auto'
|
||||
},
|
||||
name: 'Load Balanced HTTPS Route'
|
||||
}
|
||||
);
|
||||
const loadBalancerRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'proxy.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{ host: 'internal-api-1.local', port: 8443 },
|
||||
{ host: 'internal-api-2.local', port: 8443 }
|
||||
],
|
||||
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
|
||||
},
|
||||
name: 'Load Balanced HTTPS Route'
|
||||
};
|
||||
|
||||
expect(loadBalancerRoute).toBeTruthy();
|
||||
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue();
|
||||
|
||||
// Example 5: API Route
|
||||
const apiRoute = createApiRoute(
|
||||
'api.example.com',
|
||||
'/api',
|
||||
{ host: 'localhost', port: 8081 },
|
||||
{
|
||||
name: 'API Route',
|
||||
useTls: true,
|
||||
addCorsHeaders: true
|
||||
}
|
||||
);
|
||||
const apiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/api' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8081 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'API Route'
|
||||
};
|
||||
|
||||
expect(apiRoute.action.type).toEqual('forward');
|
||||
expect(apiRoute.match.path).toBeTruthy();
|
||||
|
||||
// Example 6: Complete HTTPS Server with HTTP Redirect
|
||||
const httpsServerRoutes = createCompleteHttpsServer(
|
||||
'complete.example.com',
|
||||
{
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
const httpsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'complete.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'Complete HTTPS Server'
|
||||
};
|
||||
|
||||
const httpsRedirectRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'complete.example.com' },
|
||||
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
|
||||
name: 'Complete HTTPS Server - Redirect'
|
||||
};
|
||||
|
||||
const webSocketRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/ws' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8082 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: { enabled: true }
|
||||
},
|
||||
{
|
||||
certificate: 'auto',
|
||||
name: 'Complete HTTPS Server'
|
||||
}
|
||||
);
|
||||
|
||||
expect(Array.isArray(httpsServerRoutes)).toBeTrue();
|
||||
expect(httpsServerRoutes.length).toEqual(2); // HTTPS route and HTTP redirect
|
||||
expect(httpsServerRoutes[0].action.tls?.mode).toEqual('terminate');
|
||||
expect(httpsServerRoutes[1].action.type).toEqual('socket-handler');
|
||||
|
||||
// Example 7: Static File Server - removed (use nginx/apache behind proxy)
|
||||
|
||||
// Example 8: WebSocket Route
|
||||
const webSocketRoute = createWebSocketRoute(
|
||||
'ws.example.com',
|
||||
'/ws',
|
||||
{ host: 'localhost', port: 8082 },
|
||||
{
|
||||
useTls: true,
|
||||
name: 'WebSocket Route'
|
||||
}
|
||||
);
|
||||
name: 'WebSocket Route'
|
||||
};
|
||||
|
||||
expect(webSocketRoute.action.type).toEqual('forward');
|
||||
expect(webSocketRoute.action.websocket?.enabled).toBeTrue();
|
||||
|
||||
// Create a SmartProxy instance with all routes
|
||||
const allRoutes: IRouteConfig[] = [
|
||||
httpOnlyRoute,
|
||||
httpsPassthroughRoute,
|
||||
@@ -149,19 +104,17 @@ tap.test('Route-based configuration examples', async (tools) => {
|
||||
httpToHttpsRedirect,
|
||||
loadBalancerRoute,
|
||||
apiRoute,
|
||||
...httpsServerRoutes,
|
||||
httpsRoute,
|
||||
httpsRedirectRoute,
|
||||
webSocketRoute
|
||||
];
|
||||
|
||||
// We're not actually starting the SmartProxy in this test,
|
||||
// just verifying that the configuration is valid
|
||||
const smartProxy = new SmartProxy({
|
||||
routes: allRoutes
|
||||
});
|
||||
|
||||
// Just verify that all routes are configured correctly
|
||||
console.log(`Created ${allRoutes.length} example routes`);
|
||||
expect(allRoutes.length).toEqual(9); // One less without static file route
|
||||
expect(allRoutes.length).toEqual(9);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import route-based helpers
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createHttpsPassthroughRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createCompleteHttpsServer
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
// Create helper functions for backward compatibility
|
||||
const helpers = {
|
||||
httpOnly: (domains: string | string[], target: any) => createHttpRoute(domains, target),
|
||||
tlsTerminateToHttp: (domains: string | string[], target: any) =>
|
||||
createHttpsTerminateRoute(domains, target),
|
||||
tlsTerminateToHttps: (domains: string | string[], target: any) =>
|
||||
createHttpsTerminateRoute(domains, target, { reencrypt: true }),
|
||||
httpsPassthrough: (domains: string | string[], target: any) =>
|
||||
createHttpsPassthroughRoute(domains, target)
|
||||
};
|
||||
|
||||
// Route-based utility functions for testing
|
||||
function findRouteForDomain(routes: any[], domain: string): any {
|
||||
return routes.find(route => {
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
@@ -31,55 +12,44 @@ function findRouteForDomain(routes: any[], domain: string): any {
|
||||
});
|
||||
}
|
||||
|
||||
// Replace the old test with route-based tests
|
||||
tap.test('Route Helpers - Create HTTP routes', async () => {
|
||||
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {
|
||||
const route = helpers.tlsTerminateToHttp('secure.example.com', { host: 'localhost', port: 3000 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('secure.example.com');
|
||||
expect(route.action.tls?.mode).toEqual('terminate');
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create HTTPS passthrough routes', async () => {
|
||||
const route = helpers.httpsPassthrough('passthrough.example.com', { host: 'backend', port: 443 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'passthrough.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'passthrough' } }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('passthrough.example.com');
|
||||
expect(route.action.tls?.mode).toEqual('passthrough');
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create HTTPS to HTTPS routes', async () => {
|
||||
const route = helpers.tlsTerminateToHttps('reencrypt.example.com', { host: 'backend', port: 443 });
|
||||
const route: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'reencrypt.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' } }
|
||||
};
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.match.domains).toEqual('reencrypt.example.com');
|
||||
expect(route.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - Create complete HTTPS server with redirect', async () => {
|
||||
const routes = createCompleteHttpsServer(
|
||||
'full.example.com',
|
||||
{ host: 'localhost', port: 3000 },
|
||||
{ certificate: 'auto' }
|
||||
);
|
||||
|
||||
expect(routes.length).toEqual(2);
|
||||
|
||||
// Check HTTP to HTTPS redirect - find route by port
|
||||
const redirectRoute = routes.find(r => r.match.ports === 80);
|
||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
||||
expect(redirectRoute.action.socketHandler).toBeDefined();
|
||||
expect(redirectRoute.match.ports).toEqual(80);
|
||||
|
||||
// Check HTTPS route
|
||||
const httpsRoute = routes.find(r => r.action.type === 'forward');
|
||||
expect(httpsRoute.match.ports).toEqual(443);
|
||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||
});
|
||||
|
||||
// Export test runner
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Check if we have root privileges to run NFTables tests
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
// Check if we're running as root
|
||||
const { stdout } = await exec('id -u');
|
||||
return stdout.trim() === '0';
|
||||
} catch (err) {
|
||||
@@ -17,7 +16,6 @@ async function checkRootPrivileges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tests should run
|
||||
const isRoot = await checkRootPrivileges();
|
||||
|
||||
if (!isRoot) {
|
||||
@@ -29,68 +27,70 @@ if (!isRoot) {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Define the test with proper skip condition
|
||||
const testFn = isRoot ? tap.test : tap.skip.test;
|
||||
|
||||
testFn('NFTables integration tests', async () => {
|
||||
|
||||
|
||||
console.log('Running NFTables tests with root privileges');
|
||||
|
||||
// Create test routes
|
||||
const routes = [
|
||||
createNfTablesRoute('tcp-forward', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: 9080,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
createNfTablesRoute('udp-forward', {
|
||||
host: 'localhost',
|
||||
port: 5353
|
||||
}, {
|
||||
ports: 5354,
|
||||
protocol: 'udp'
|
||||
}),
|
||||
|
||||
createNfTablesRoute('port-range', {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
}, {
|
||||
ports: [{ from: 9000, to: 9100 }],
|
||||
protocol: 'tcp'
|
||||
})
|
||||
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
match: { ports: 9080 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'tcp-forward'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: 5354 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: 5353 }],
|
||||
nftables: { protocol: 'udp' }
|
||||
},
|
||||
name: 'udp-forward'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: [{ from: 9000, to: 9100 }] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'port-range'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const smartProxy = new SmartProxy({
|
||||
enableDetailedLogging: true,
|
||||
routes
|
||||
});
|
||||
|
||||
// Start the proxy
|
||||
|
||||
await smartProxy.start();
|
||||
console.log('SmartProxy started with NFTables routes');
|
||||
|
||||
// Get NFTables status
|
||||
|
||||
const status = await smartProxy.getNfTablesStatus();
|
||||
console.log('NFTables status:', JSON.stringify(status, null, 2));
|
||||
|
||||
// Verify all routes are provisioned
|
||||
|
||||
expect(Object.keys(status).length).toEqual(routes.length);
|
||||
|
||||
|
||||
for (const routeStatus of Object.values(status)) {
|
||||
expect(routeStatus.active).toBeTrue();
|
||||
expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Stop the proxy
|
||||
|
||||
await smartProxy.stop();
|
||||
console.log('SmartProxy stopped');
|
||||
|
||||
// Verify all rules are cleaned up
|
||||
|
||||
const finalStatus = await smartProxy.getNfTablesStatus();
|
||||
expect(Object.keys(finalStatus).length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as http from 'http';
|
||||
@@ -10,13 +9,13 @@ import { fileURLToPath } from 'url';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Check if we have root privileges
|
||||
async function checkRootPrivileges(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await exec('id -u');
|
||||
@@ -26,7 +25,6 @@ async function checkRootPrivileges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tests should run
|
||||
const runTests = await checkRootPrivileges();
|
||||
|
||||
if (!runTests) {
|
||||
@@ -36,10 +34,8 @@ if (!runTests) {
|
||||
console.log('Skipping NFTables integration tests');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
// Skip tests when not running as root - tests are marked with tap.skip.test
|
||||
}
|
||||
|
||||
// Test server and client utilities
|
||||
let testTcpServer: net.Server;
|
||||
let testHttpServer: http.Server;
|
||||
let testHttpsServer: https.Server;
|
||||
@@ -53,10 +49,8 @@ const PROXY_HTTP_PORT = 5001;
|
||||
const PROXY_HTTPS_PORT = 5002;
|
||||
const TEST_DATA = 'Hello through NFTables!';
|
||||
|
||||
// Helper to create test certificates
|
||||
async function createTestCertificates() {
|
||||
try {
|
||||
// Import the certificate helper
|
||||
const certsModule = await import('./helpers/certificates.js');
|
||||
const certificates = certsModule.loadTestCertificates();
|
||||
return {
|
||||
@@ -65,7 +59,6 @@ async function createTestCertificates() {
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load test certificates:', err);
|
||||
// Use dummy certificates for testing
|
||||
return {
|
||||
cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'),
|
||||
key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8')
|
||||
@@ -75,111 +68,112 @@ async function createTestCertificates() {
|
||||
|
||||
tap.skip.test('setup NFTables integration test environment', async () => {
|
||||
console.log('Running NFTables integration tests with root privileges');
|
||||
|
||||
// Create a basic TCP test server
|
||||
|
||||
testTcpServer = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
socket.write(`Server says: ${data.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testTcpServer.listen(TEST_TCP_PORT, () => {
|
||||
console.log(`TCP test server listening on port ${TEST_TCP_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create an HTTP test server
|
||||
|
||||
testHttpServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(`HTTP Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpServer.listen(TEST_HTTP_PORT, () => {
|
||||
console.log(`HTTP test server listening on port ${TEST_HTTP_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create an HTTPS test server
|
||||
|
||||
const certs = await createTestCertificates();
|
||||
testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end(`HTTPS Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpsServer.listen(TEST_HTTPS_PORT, () => {
|
||||
console.log(`HTTPS test server listening on port ${TEST_HTTPS_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Create SmartProxy with various NFTables routes
|
||||
|
||||
smartProxy = new SmartProxy({
|
||||
enableDetailedLogging: true,
|
||||
routes: [
|
||||
// TCP forwarding route
|
||||
createNfTablesRoute('tcp-nftables', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: PROXY_TCP_PORT,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
// HTTP forwarding route
|
||||
createNfTablesRoute('http-nftables', {
|
||||
host: 'localhost',
|
||||
port: TEST_HTTP_PORT
|
||||
}, {
|
||||
ports: PROXY_HTTP_PORT,
|
||||
protocol: 'tcp'
|
||||
}),
|
||||
|
||||
// HTTPS termination route
|
||||
createNfTablesTerminateRoute('https-nftables.example.com', {
|
||||
host: 'localhost',
|
||||
port: TEST_HTTPS_PORT
|
||||
}, {
|
||||
ports: PROXY_HTTPS_PORT,
|
||||
protocol: 'tcp',
|
||||
certificate: certs
|
||||
}),
|
||||
|
||||
// Route with IP allow list
|
||||
createNfTablesRoute('secure-tcp', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: 5003,
|
||||
protocol: 'tcp',
|
||||
ipAllowList: ['127.0.0.1', '::1']
|
||||
}),
|
||||
|
||||
// Route with QoS settings
|
||||
createNfTablesRoute('qos-tcp', {
|
||||
host: 'localhost',
|
||||
port: TEST_TCP_PORT
|
||||
}, {
|
||||
ports: 5004,
|
||||
protocol: 'tcp',
|
||||
maxRate: '10mbps',
|
||||
priority: 1
|
||||
})
|
||||
{
|
||||
match: { ports: PROXY_TCP_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'tcp-nftables'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: PROXY_HTTP_PORT },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_HTTP_PORT }],
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'http-nftables'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: PROXY_HTTPS_PORT, domains: 'https-nftables.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_HTTPS_PORT }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
nftables: { protocol: 'tcp' }
|
||||
},
|
||||
name: 'https-nftables'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: 5003 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||
nftables: { protocol: 'tcp', ipAllowList: ['127.0.0.1', '::1'] }
|
||||
},
|
||||
name: 'secure-tcp'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: 5004 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forwardingEngine: 'nftables',
|
||||
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
|
||||
nftables: { protocol: 'tcp', maxRate: '10mbps', priority: 1 }
|
||||
},
|
||||
name: 'qos-tcp'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
console.log('SmartProxy created, now starting...');
|
||||
|
||||
// Start the proxy
|
||||
|
||||
try {
|
||||
await smartProxy.start();
|
||||
console.log('SmartProxy started successfully');
|
||||
|
||||
// Verify proxy is listening on expected ports
|
||||
|
||||
const listeningPorts = smartProxy.getListeningPorts();
|
||||
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
|
||||
} catch (err) {
|
||||
@@ -190,8 +184,7 @@ tap.skip.test('setup NFTables integration test environment', async () => {
|
||||
|
||||
tap.skip.test('should forward TCP connections through NFTables', async () => {
|
||||
console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`);
|
||||
|
||||
// First verify our test server is running
|
||||
|
||||
try {
|
||||
const testClient = new net.Socket();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -205,40 +198,39 @@ tap.skip.test('should forward TCP connections through NFTables', async () => {
|
||||
} catch (err) {
|
||||
console.error(`Test server on port ${TEST_TCP_PORT} is not accessible: ${err}`);
|
||||
}
|
||||
|
||||
// Connect to the proxy port
|
||||
|
||||
const client = new net.Socket();
|
||||
|
||||
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
let responseData = '';
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
reject(new Error(`Connection timeout after 5 seconds to proxy port ${PROXY_TCP_PORT}`));
|
||||
}, 5000);
|
||||
|
||||
|
||||
client.connect(PROXY_TCP_PORT, 'localhost', () => {
|
||||
console.log(`Connected to proxy port ${PROXY_TCP_PORT}, sending data...`);
|
||||
client.write(TEST_DATA);
|
||||
});
|
||||
|
||||
|
||||
client.on('data', (data) => {
|
||||
console.log(`Received data from proxy: ${data.toString()}`);
|
||||
responseData += data.toString();
|
||||
client.end();
|
||||
});
|
||||
|
||||
|
||||
client.on('end', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(responseData);
|
||||
});
|
||||
|
||||
|
||||
client.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`Connection error on proxy port ${PROXY_TCP_PORT}: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(response).toEqual(`Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
@@ -254,21 +246,20 @@ tap.skip.test('should forward HTTP connections through NFTables', async () => {
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
|
||||
expect(response).toEqual(`HTTP Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.skip.test('should handle HTTPS termination with NFTables', async () => {
|
||||
// Skip this test if running without proper certificates
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: PROXY_HTTPS_PORT,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false // For self-signed cert
|
||||
rejectUnauthorized: false
|
||||
};
|
||||
|
||||
|
||||
https.get(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
@@ -279,43 +270,40 @@ tap.skip.test('should handle HTTPS termination with NFTables', async () => {
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
|
||||
expect(response).toEqual(`HTTPS Server says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
tap.skip.test('should respect IP allow lists in NFTables', async () => {
|
||||
// This test should pass since we're connecting from localhost
|
||||
const client = new net.Socket();
|
||||
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
resolve(false);
|
||||
}, 2000);
|
||||
|
||||
|
||||
client.connect(5003, 'localhost', () => {
|
||||
clearTimeout(timeout);
|
||||
client.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
|
||||
client.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
expect(connected).toBeTrue();
|
||||
});
|
||||
|
||||
tap.skip.test('should get NFTables status', async () => {
|
||||
const status = await smartProxy.getNfTablesStatus();
|
||||
|
||||
// Check that we have status for our routes
|
||||
|
||||
const statusKeys = Object.keys(status);
|
||||
expect(statusKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// Check status structure for one of the routes
|
||||
|
||||
const firstStatus = status[statusKeys[0]];
|
||||
expect(firstStatus).toHaveProperty('active');
|
||||
expect(firstStatus).toHaveProperty('ruleCount');
|
||||
@@ -324,21 +312,20 @@ tap.skip.test('should get NFTables status', async () => {
|
||||
});
|
||||
|
||||
tap.skip.test('cleanup NFTables integration test environment', async () => {
|
||||
// Stop the proxy and test servers
|
||||
await smartProxy.stop();
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testTcpServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
testHttpsServer.close(() => {
|
||||
resolve();
|
||||
@@ -346,4 +333,4 @@ tap.skip.test('cleanup NFTables integration test environment', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import {
|
||||
createPortMappingRoute,
|
||||
createOffsetPortMappingRoute,
|
||||
createDynamicRoute,
|
||||
createSmartLoadBalancer,
|
||||
createPortOffset
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
|
||||
|
||||
// Test server and client utilities
|
||||
let testServers: Array<{ server: net.Server; port: number }> = [];
|
||||
let smartProxy: SmartProxy;
|
||||
|
||||
let TEST_PORTS: number[]; // 3 test server ports
|
||||
let PROXY_PORTS: number[]; // 6 proxy ports
|
||||
let TEST_PORTS: number[];
|
||||
let PROXY_PORTS: number[];
|
||||
const TEST_DATA = 'Hello through dynamic port mapper!';
|
||||
|
||||
// Cleanup function to close all servers and proxies
|
||||
function cleanup() {
|
||||
console.log('Starting cleanup...');
|
||||
const promises = [];
|
||||
|
||||
// Close test servers
|
||||
|
||||
for (const { server, port } of testServers) {
|
||||
promises.push(new Promise<void>(resolve => {
|
||||
console.log(`Closing test server on port ${port}`);
|
||||
@@ -34,31 +24,28 @@ function cleanup() {
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Stop SmartProxy
|
||||
|
||||
if (smartProxy) {
|
||||
console.log('Stopping SmartProxy...');
|
||||
promises.push(smartProxy.stop().then(() => {
|
||||
console.log('SmartProxy stopped');
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
// Helper: Creates a test TCP server that listens on a given port
|
||||
function createTestServer(port: number): Promise<net.Server> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
// Echo the received data back with a server identifier
|
||||
socket.write(`Server ${port} says: ${data.toString()}`);
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
console.error(`[Test Server] Socket error on port ${port}:`, error);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`[Test Server] Listening on port ${port}`);
|
||||
testServers.push({ server, port });
|
||||
@@ -67,32 +54,31 @@ function createTestServer(port: number): Promise<net.Server> {
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Creates a test client connection with timeout
|
||||
function createTestClient(port: number, data: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new net.Socket();
|
||||
let response = '';
|
||||
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
client.destroy();
|
||||
reject(new Error(`Client connection timeout to port ${port}`));
|
||||
}, 5000);
|
||||
|
||||
|
||||
client.connect(port, 'localhost', () => {
|
||||
console.log(`[Test Client] Connected to server on port ${port}`);
|
||||
client.write(data);
|
||||
});
|
||||
|
||||
|
||||
client.on('data', (chunk) => {
|
||||
response += chunk.toString();
|
||||
client.end();
|
||||
});
|
||||
|
||||
|
||||
client.on('end', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
});
|
||||
|
||||
|
||||
client.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
@@ -100,123 +86,108 @@ function createTestClient(port: number, data: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// Set up test environment
|
||||
tap.test('setup port mapping test environment', async () => {
|
||||
const allPorts = await findFreePorts(9);
|
||||
TEST_PORTS = allPorts.slice(0, 3);
|
||||
PROXY_PORTS = allPorts.slice(3, 9);
|
||||
|
||||
// Create multiple test servers on different ports
|
||||
await Promise.all([
|
||||
createTestServer(TEST_PORTS[0]),
|
||||
createTestServer(TEST_PORTS[1]),
|
||||
createTestServer(TEST_PORTS[2]),
|
||||
]);
|
||||
|
||||
// Compute dynamic offset between proxy and test ports
|
||||
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
|
||||
|
||||
// Create a SmartProxy with dynamic port mapping routes
|
||||
smartProxy = new SmartProxy({
|
||||
routes: [
|
||||
// Simple function that returns the same port (identity mapping)
|
||||
createPortMappingRoute({
|
||||
sourcePortRange: PROXY_PORTS[0],
|
||||
targetHost: 'localhost',
|
||||
portMapper: (context) => TEST_PORTS[0],
|
||||
name: 'Identity Port Mapping'
|
||||
}),
|
||||
|
||||
// Offset port mapping using dynamic offset
|
||||
createOffsetPortMappingRoute({
|
||||
ports: PROXY_PORTS[1],
|
||||
targetHost: 'localhost',
|
||||
offset: portOffset,
|
||||
name: `Offset Port Mapping (${portOffset})`
|
||||
}),
|
||||
|
||||
// Dynamic route with conditional port mapping
|
||||
createDynamicRoute({
|
||||
ports: [PROXY_PORTS[2], PROXY_PORTS[3]],
|
||||
targetHost: (context) => {
|
||||
// Dynamic host selection based on port
|
||||
return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1';
|
||||
{
|
||||
match: { ports: PROXY_PORTS[0] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: (context: IRouteContext) => TEST_PORTS[0]
|
||||
}]
|
||||
},
|
||||
portMapper: (context) => {
|
||||
// Port mapping logic based on incoming port
|
||||
if (context.port === PROXY_PORTS[2]) {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[2];
|
||||
}
|
||||
name: 'Identity Port Mapping'
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: PROXY_PORTS[1] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: 'localhost',
|
||||
port: (context: IRouteContext) => context.port + portOffset
|
||||
}]
|
||||
},
|
||||
name: `Offset Port Mapping (${portOffset})`
|
||||
},
|
||||
|
||||
{
|
||||
match: { ports: [PROXY_PORTS[2], PROXY_PORTS[3]] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: (context: IRouteContext) => {
|
||||
return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1';
|
||||
},
|
||||
port: (context: IRouteContext) => {
|
||||
if (context.port === PROXY_PORTS[2]) {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[2];
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
name: 'Dynamic Host and Port Mapping'
|
||||
}),
|
||||
},
|
||||
|
||||
// Smart load balancer for domain-based routing
|
||||
createSmartLoadBalancer({
|
||||
ports: PROXY_PORTS[4],
|
||||
domainTargets: {
|
||||
'test1.example.com': 'localhost',
|
||||
'test2.example.com': '127.0.0.1'
|
||||
{
|
||||
match: { ports: PROXY_PORTS[4] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: (context: IRouteContext) => {
|
||||
if (context.domain === 'test1.example.com') return 'localhost';
|
||||
if (context.domain === 'test2.example.com') return '127.0.0.1';
|
||||
return 'localhost';
|
||||
},
|
||||
port: (context: IRouteContext) => {
|
||||
if (context.domain === 'test1.example.com') {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[1];
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
portMapper: (context) => {
|
||||
// Use different backend ports based on domain
|
||||
if (context.domain === 'test1.example.com') {
|
||||
return TEST_PORTS[0];
|
||||
} else {
|
||||
return TEST_PORTS[1];
|
||||
}
|
||||
},
|
||||
defaultTarget: 'localhost',
|
||||
name: 'Smart Domain Load Balancer'
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Start the SmartProxy
|
||||
await smartProxy.start();
|
||||
});
|
||||
|
||||
// Test 1: Simple identity port mapping
|
||||
tap.test('should map port using identity function', async () => {
|
||||
const response = await createTestClient(PROXY_PORTS[0], TEST_DATA);
|
||||
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
// Test 2: Offset port mapping
|
||||
tap.test('should map port using offset function', async () => {
|
||||
const response = await createTestClient(PROXY_PORTS[1], TEST_DATA);
|
||||
expect(response).toEqual(`Server ${TEST_PORTS[1]} says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
// Test 3: Dynamic port and host mapping (conditional logic)
|
||||
tap.test('should map port using dynamic logic', async () => {
|
||||
const response = await createTestClient(PROXY_PORTS[2], TEST_DATA);
|
||||
expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
|
||||
});
|
||||
|
||||
// Test 4: Test reuse of createPortOffset helper
|
||||
tap.test('should use createPortOffset helper for port mapping', async () => {
|
||||
// Test the createPortOffset helper with dynamic offset
|
||||
const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
|
||||
const offsetFn = createPortOffset(portOffset);
|
||||
const context = {
|
||||
port: PROXY_PORTS[1],
|
||||
clientIp: '127.0.0.1',
|
||||
serverIp: '127.0.0.1',
|
||||
isTls: false,
|
||||
timestamp: Date.now(),
|
||||
connectionId: 'test-connection'
|
||||
} as IRouteContext;
|
||||
|
||||
const mappedPort = offsetFn(context);
|
||||
expect(mappedPort).toEqual(TEST_PORTS[1]);
|
||||
});
|
||||
|
||||
// Test 5: Test error handling for invalid port mapping functions
|
||||
tap.test('should handle errors in port mapping functions', async () => {
|
||||
// Create a route with a function that throws an error
|
||||
const errorRoute: IRouteConfig = {
|
||||
match: {
|
||||
ports: PROXY_PORTS[5]
|
||||
@@ -232,34 +203,27 @@ tap.test('should handle errors in port mapping functions', async () => {
|
||||
},
|
||||
name: 'Error Route'
|
||||
};
|
||||
|
||||
// Add the route to SmartProxy
|
||||
|
||||
await smartProxy.updateRoutes([...smartProxy.settings.routes, errorRoute]);
|
||||
|
||||
// The connection should fail or timeout
|
||||
|
||||
try {
|
||||
await createTestClient(PROXY_PORTS[5], TEST_DATA);
|
||||
// Connection should not succeed
|
||||
expect(false).toBeTrue();
|
||||
} catch (error) {
|
||||
// Connection failed as expected
|
||||
expect(true).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
tap.test('cleanup port mapping test environment', async () => {
|
||||
// Add timeout to prevent hanging if SmartProxy shutdown has issues
|
||||
const cleanupPromise = cleanup();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Cleanup timeout after 5 seconds')), 5000)
|
||||
);
|
||||
|
||||
|
||||
try {
|
||||
await Promise.race([cleanupPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.error('Cleanup error:', error);
|
||||
// Force cleanup even if there's an error
|
||||
testServers = [];
|
||||
smartProxy = null as any;
|
||||
}
|
||||
@@ -267,4 +231,4 @@ tap.test('cleanup port mapping test environment', async () => {
|
||||
await assertPortsFree([...TEST_PORTS, ...PROXY_PORTS]);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
// Import from core modules
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
|
||||
// Import route utilities and helpers
|
||||
// Import route utilities
|
||||
import {
|
||||
findMatchingRoutes,
|
||||
findBestMatchingRoute,
|
||||
@@ -28,16 +28,7 @@ import {
|
||||
assertValidRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
|
||||
import {
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createHttpsPassthroughRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createCompleteHttpsServer,
|
||||
createLoadBalancerRoute,
|
||||
createApiRoute,
|
||||
createWebSocketRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
|
||||
|
||||
// Import test helpers
|
||||
import { loadTestCertificates } from './helpers/certificates.js';
|
||||
@@ -47,12 +38,12 @@ import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.
|
||||
// --------------------------------- Route Creation Tests ---------------------------------
|
||||
|
||||
tap.test('Routes: Should create basic HTTP route', async () => {
|
||||
// Create a simple HTTP route
|
||||
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
const httpRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'Basic HTTP Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(httpRoute.match.ports).toEqual(80);
|
||||
expect(httpRoute.match.domains).toEqual('example.com');
|
||||
expect(httpRoute.action.type).toEqual('forward');
|
||||
@@ -62,14 +53,17 @@ tap.test('Routes: Should create basic HTTP route', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
|
||||
// Create an HTTPS route with TLS termination
|
||||
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8080 }, {
|
||||
certificate: 'auto',
|
||||
const httpsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'HTTPS Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(httpsRoute.match.ports).toEqual(443); // Default HTTPS port
|
||||
expect(httpsRoute.match.ports).toEqual(443);
|
||||
expect(httpsRoute.match.domains).toEqual('secure.example.com');
|
||||
expect(httpsRoute.action.type).toEqual('forward');
|
||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||
@@ -80,10 +74,15 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
|
||||
// Create an HTTP to HTTPS redirect
|
||||
const redirectRoute = createHttpToHttpsRedirect('example.com', 443);
|
||||
const redirectRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
},
|
||||
name: 'HTTP to HTTPS Redirect for example.com'
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(redirectRoute.match.ports).toEqual(80);
|
||||
expect(redirectRoute.match.domains).toEqual('example.com');
|
||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
||||
@@ -91,22 +90,34 @@ tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create complete HTTPS server with redirects', async () => {
|
||||
// Create a complete HTTPS server setup
|
||||
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 8080 }, {
|
||||
certificate: 'auto'
|
||||
});
|
||||
const routes: IRouteConfig[] = [
|
||||
{
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'HTTPS Terminate Route for example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
},
|
||||
name: 'HTTP to HTTPS Redirect for example.com'
|
||||
}
|
||||
];
|
||||
|
||||
// Validate that we got two routes (HTTPS route and HTTP redirect)
|
||||
expect(routes.length).toEqual(2);
|
||||
|
||||
// Validate HTTPS route
|
||||
const httpsRoute = routes[0];
|
||||
expect(httpsRoute.match.ports).toEqual(443);
|
||||
expect(httpsRoute.match.domains).toEqual('example.com');
|
||||
expect(httpsRoute.action.type).toEqual('forward');
|
||||
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
|
||||
|
||||
// Validate HTTP redirect route
|
||||
const redirectRoute = routes[1];
|
||||
expect(redirectRoute.match.ports).toEqual(80);
|
||||
expect(redirectRoute.action.type).toEqual('socket-handler');
|
||||
@@ -114,21 +125,17 @@ tap.test('Routes: Should create complete HTTPS server with redirects', async ()
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create load balancer route', async () => {
|
||||
// Create a load balancer route
|
||||
const lbRoute = createLoadBalancerRoute(
|
||||
'app.example.com',
|
||||
['10.0.0.1', '10.0.0.2', '10.0.0.3'],
|
||||
8080,
|
||||
{
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
},
|
||||
name: 'Load Balanced Route'
|
||||
}
|
||||
);
|
||||
const lbRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
loadBalancing: { algorithm: 'round-robin' }
|
||||
},
|
||||
name: 'Load Balanced Route'
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(lbRoute.match.domains).toEqual('app.example.com');
|
||||
expect(lbRoute.action.type).toEqual('forward');
|
||||
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
|
||||
@@ -139,23 +146,32 @@ tap.test('Routes: Should create load balancer route', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create API route with CORS', async () => {
|
||||
// Create an API route with CORS headers
|
||||
const apiRoute = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true,
|
||||
const apiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
headers: {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
},
|
||||
priority: 100,
|
||||
name: 'API Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(apiRoute.match.domains).toEqual('api.example.com');
|
||||
expect(apiRoute.match.path).toEqual('/v1/*');
|
||||
expect(apiRoute.action.type).toEqual('forward');
|
||||
expect(apiRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check CORS headers
|
||||
|
||||
expect(apiRoute.headers).toBeDefined();
|
||||
if (apiRoute.headers?.response) {
|
||||
expect(apiRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
||||
@@ -164,23 +180,25 @@ tap.test('Routes: Should create API route with CORS', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Should create WebSocket route', async () => {
|
||||
// Create a WebSocket route
|
||||
const wsRoute = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 5000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
pingInterval: 15000,
|
||||
const wsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 5000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: { enabled: true, pingInterval: 15000 }
|
||||
},
|
||||
priority: 100,
|
||||
name: 'WebSocket Route'
|
||||
});
|
||||
};
|
||||
|
||||
// Validate the route configuration
|
||||
expect(wsRoute.match.domains).toEqual('ws.example.com');
|
||||
expect(wsRoute.match.path).toEqual('/socket');
|
||||
expect(wsRoute.action.type).toEqual('forward');
|
||||
expect(wsRoute.action.tls?.mode).toEqual('terminate');
|
||||
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
|
||||
|
||||
// Check WebSocket configuration
|
||||
|
||||
expect(wsRoute.action.websocket).toBeDefined();
|
||||
if (wsRoute.action.websocket) {
|
||||
expect(wsRoute.action.websocket.enabled).toBeTrue();
|
||||
@@ -191,22 +209,27 @@ tap.test('Routes: Should create WebSocket route', async () => {
|
||||
// Static file serving has been removed - should be handled by external servers
|
||||
|
||||
tap.test('SmartProxy: Should create instance with route-based config', async () => {
|
||||
// Create TLS certificates for testing
|
||||
const certs = loadTestCertificates();
|
||||
|
||||
// Create a SmartProxy instance with route-based configuration
|
||||
const proxy = new SmartProxy({
|
||||
routes: [
|
||||
createHttpRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route'
|
||||
}),
|
||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8443 }, {
|
||||
certificate: {
|
||||
key: certs.privateKey,
|
||||
cert: certs.publicKey
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8443 }],
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: { key: certs.privateKey, cert: certs.publicKey }
|
||||
}
|
||||
},
|
||||
name: 'HTTPS Route'
|
||||
})
|
||||
}
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
@@ -218,13 +241,11 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
||||
maxConnections: 100
|
||||
}
|
||||
},
|
||||
// Additional settings
|
||||
initialDataTimeout: 10000,
|
||||
inactivityTimeout: 300000,
|
||||
enableDetailedLogging: true
|
||||
});
|
||||
|
||||
// Simply verify the instance was created successfully
|
||||
expect(typeof proxy).toEqual('object');
|
||||
expect(typeof proxy.start).toEqual('function');
|
||||
expect(typeof proxy.stop).toEqual('function');
|
||||
@@ -233,94 +254,109 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
|
||||
// --------------------------------- Edge Case Tests ---------------------------------
|
||||
|
||||
tap.test('Edge Case - Empty Routes Array', async () => {
|
||||
// Attempting to find routes in an empty array
|
||||
const emptyRoutes: IRouteConfig[] = [];
|
||||
const matches = findMatchingRoutes(emptyRoutes, { domain: 'example.com', port: 80 });
|
||||
|
||||
|
||||
expect(matches).toBeInstanceOf(Array);
|
||||
expect(matches.length).toEqual(0);
|
||||
|
||||
|
||||
const bestMatch = findBestMatchingRoute(emptyRoutes, { domain: 'example.com', port: 80 });
|
||||
expect(bestMatch).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Multiple Matching Routes with Same Priority', async () => {
|
||||
// Create multiple routes with identical priority but different targets
|
||||
const route1 = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
||||
const route2 = createHttpRoute('example.com', { host: 'server2', port: 3000 });
|
||||
const route3 = createHttpRoute('example.com', { host: 'server3', port: 3000 });
|
||||
|
||||
// Set all to the same priority
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server2', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const route3: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server3', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
|
||||
route1.priority = 100;
|
||||
route2.priority = 100;
|
||||
route3.priority = 100;
|
||||
|
||||
|
||||
const routes = [route1, route2, route3];
|
||||
|
||||
// Find matching routes
|
||||
|
||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||
|
||||
// Should find all three routes
|
||||
|
||||
expect(matches.length).toEqual(3);
|
||||
|
||||
// First match could be any of the routes since they have the same priority
|
||||
// But the implementation should be consistent (likely keep the original order)
|
||||
|
||||
const bestMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
expect(bestMatch).not.toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
|
||||
// Create routes with wildcard domains and path patterns
|
||||
const wildcardApiRoute = createApiRoute('*.example.com', '/api', { host: 'api-server', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto'
|
||||
});
|
||||
|
||||
const exactApiRoute = createApiRoute('api.example.com', '/api', { host: 'specific-api-server', port: 3001 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
priority: 200 // Higher priority
|
||||
});
|
||||
|
||||
const wildcardApiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: '*.example.com', path: '/api/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'api-server', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
priority: 100,
|
||||
name: 'API Route for *.example.com'
|
||||
};
|
||||
|
||||
const exactApiRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'specific-api-server', port: 3001 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
priority: 200,
|
||||
name: 'API Route for api.example.com'
|
||||
};
|
||||
|
||||
const routes = [wildcardApiRoute, exactApiRoute];
|
||||
|
||||
// Test with a specific subdomain that matches both routes
|
||||
|
||||
const matches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
||||
|
||||
// Should match both routes
|
||||
|
||||
expect(matches.length).toEqual(2);
|
||||
|
||||
// The exact domain match should have higher priority
|
||||
|
||||
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
|
||||
expect(bestMatch).not.toBeUndefined();
|
||||
if (bestMatch) {
|
||||
expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route
|
||||
expect(bestMatch.action.targets[0].port).toEqual(3001);
|
||||
}
|
||||
|
||||
// Test with a different subdomain - should only match the wildcard route
|
||||
|
||||
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
|
||||
expect(otherMatches.length).toEqual(1);
|
||||
expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route
|
||||
expect(otherMatches[0].action.targets[0].port).toEqual(3000);
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Disabled Routes', async () => {
|
||||
// Create enabled and disabled routes
|
||||
const enabledRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
||||
const disabledRoute = createHttpRoute('example.com', { host: 'server2', port: 3001 });
|
||||
const enabledRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const disabledRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
disabledRoute.enabled = false;
|
||||
|
||||
|
||||
const routes = [enabledRoute, disabledRoute];
|
||||
|
||||
// Find matching routes
|
||||
|
||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||
|
||||
// Should only find the enabled route
|
||||
|
||||
expect(matches.length).toEqual(1);
|
||||
expect(matches[0].action.targets[0].port).toEqual(3000);
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
// Create route with complex path and headers matching
|
||||
const complexRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'api.example.com',
|
||||
@@ -344,22 +380,20 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
},
|
||||
name: 'Complex API Route'
|
||||
};
|
||||
|
||||
// Test with matching criteria
|
||||
|
||||
const matchingPath = routeMatchesPath(complexRoute, '/api/v2/users');
|
||||
expect(matchingPath).toBeTrue();
|
||||
|
||||
|
||||
const matchingHeaders = routeMatchesHeaders(complexRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'valid-key',
|
||||
'Accept': 'application/json'
|
||||
});
|
||||
expect(matchingHeaders).toBeTrue();
|
||||
|
||||
// Test with non-matching criteria
|
||||
|
||||
const nonMatchingPath = routeMatchesPath(complexRoute, '/api/v1/users');
|
||||
expect(nonMatchingPath).toBeFalse();
|
||||
|
||||
|
||||
const nonMatchingHeaders = routeMatchesHeaders(complexRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'invalid-key'
|
||||
@@ -368,7 +402,6 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
|
||||
});
|
||||
|
||||
tap.test('Edge Case - Port Range Matching', async () => {
|
||||
// Create route with port range matching
|
||||
const portRangeRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -383,17 +416,14 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
},
|
||||
name: 'Port Range Route'
|
||||
};
|
||||
|
||||
// Test with ports in the range
|
||||
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue(); // Lower bound
|
||||
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue(); // Middle
|
||||
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue(); // Upper bound
|
||||
|
||||
// Test with ports outside the range
|
||||
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse(); // Just below
|
||||
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse(); // Just above
|
||||
|
||||
// Test with multiple port ranges
|
||||
|
||||
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
|
||||
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
|
||||
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue();
|
||||
|
||||
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse();
|
||||
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse();
|
||||
|
||||
const multiRangeRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -411,7 +441,7 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
},
|
||||
name: 'Multi Range Route'
|
||||
};
|
||||
|
||||
|
||||
expect(routeMatchesPort(multiRangeRoute, 85)).toBeTrue();
|
||||
expect(routeMatchesPort(multiRangeRoute, 8500)).toBeTrue();
|
||||
expect(routeMatchesPort(multiRangeRoute, 100)).toBeFalse();
|
||||
@@ -420,55 +450,56 @@ tap.test('Edge Case - Port Range Matching', async () => {
|
||||
// --------------------------------- Wildcard Domain Tests ---------------------------------
|
||||
|
||||
tap.test('Wildcard Domain Handling', async () => {
|
||||
// Create routes with different wildcard patterns
|
||||
const simpleDomainRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 });
|
||||
const wildcardSubdomainRoute = createHttpRoute('*.example.com', { host: 'server2', port: 3001 });
|
||||
const specificSubdomainRoute = createHttpRoute('api.example.com', { host: 'server3', port: 3002 });
|
||||
const simpleDomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com'
|
||||
};
|
||||
const wildcardSubdomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: '*.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
|
||||
name: 'HTTP Route for *.example.com'
|
||||
};
|
||||
const specificSubdomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'api.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'server3', port: 3002 }] },
|
||||
name: 'HTTP Route for api.example.com'
|
||||
};
|
||||
|
||||
// Set explicit priorities to ensure deterministic matching
|
||||
specificSubdomainRoute.priority = 200; // Highest priority for specific domain
|
||||
wildcardSubdomainRoute.priority = 100; // Medium priority for wildcard
|
||||
simpleDomainRoute.priority = 50; // Lowest priority for generic domain
|
||||
specificSubdomainRoute.priority = 200;
|
||||
wildcardSubdomainRoute.priority = 100;
|
||||
simpleDomainRoute.priority = 50;
|
||||
|
||||
const routes = [simpleDomainRoute, wildcardSubdomainRoute, specificSubdomainRoute];
|
||||
|
||||
// Test exact domain match
|
||||
expect(routeMatchesDomain(simpleDomainRoute, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(simpleDomainRoute, 'sub.example.com')).toBeFalse();
|
||||
|
||||
// Test wildcard subdomain match
|
||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'any.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'nested.sub.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardSubdomainRoute, 'example.com')).toBeFalse();
|
||||
|
||||
// Test specific subdomain match
|
||||
expect(routeMatchesDomain(specificSubdomainRoute, 'api.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(specificSubdomainRoute, 'other.example.com')).toBeFalse();
|
||||
expect(routeMatchesDomain(specificSubdomainRoute, 'sub.api.example.com')).toBeFalse();
|
||||
|
||||
// Test finding best match when multiple domains match
|
||||
const specificSubdomainRequest = { domain: 'api.example.com', port: 80 };
|
||||
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
|
||||
expect(bestSpecificMatch).not.toBeUndefined();
|
||||
if (bestSpecificMatch) {
|
||||
// Find which route was matched
|
||||
const matchedPort = bestSpecificMatch.action.targets[0].port;
|
||||
console.log(`Matched route with port: ${matchedPort}`);
|
||||
|
||||
// Verify it's the specific subdomain route (with highest priority)
|
||||
expect(bestSpecificMatch.priority).toEqual(200);
|
||||
}
|
||||
|
||||
// Test with a subdomain that matches wildcard but not specific
|
||||
const otherSubdomainRequest = { domain: 'other.example.com', port: 80 };
|
||||
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
|
||||
expect(bestWildcardMatch).not.toBeUndefined();
|
||||
if (bestWildcardMatch) {
|
||||
// Find which route was matched
|
||||
const matchedPort = bestWildcardMatch.action.targets[0].port;
|
||||
console.log(`Matched route with port: ${matchedPort}`);
|
||||
|
||||
// Verify it's the wildcard subdomain route (with medium priority)
|
||||
expect(bestWildcardMatch.priority).toEqual(100);
|
||||
}
|
||||
});
|
||||
@@ -476,56 +507,83 @@ tap.test('Wildcard Domain Handling', async () => {
|
||||
// --------------------------------- Integration Tests ---------------------------------
|
||||
|
||||
tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
// Create a comprehensive set of routes for a full application
|
||||
const routes: IRouteConfig[] = [
|
||||
// Main website with HTTPS and HTTP redirect
|
||||
...createCompleteHttpsServer('example.com', { host: 'web-server', port: 8080 }, {
|
||||
certificate: 'auto'
|
||||
}),
|
||||
|
||||
// API endpoints
|
||||
createApiRoute('api.example.com', '/v1', { host: 'api-server', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true
|
||||
}),
|
||||
|
||||
// WebSocket for real-time updates
|
||||
createWebSocketRoute('ws.example.com', '/live', { host: 'websocket-server', port: 5000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto'
|
||||
}),
|
||||
|
||||
|
||||
// Legacy system with passthrough
|
||||
createHttpsPassthroughRoute('legacy.example.com', { host: 'legacy-server', port: 443 })
|
||||
{
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'web-server', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
name: 'HTTPS Terminate Route for example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
|
||||
},
|
||||
name: 'HTTP to HTTPS Redirect for example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'api-server', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
headers: {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
},
|
||||
priority: 100,
|
||||
name: 'API Route for api.example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/live' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'websocket-server', port: 5000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: { enabled: true }
|
||||
},
|
||||
priority: 100,
|
||||
name: 'WebSocket Route for ws.example.com'
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'legacy.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'legacy-server', port: 443 }],
|
||||
tls: { mode: 'passthrough' }
|
||||
},
|
||||
name: 'HTTPS Passthrough Route for legacy.example.com'
|
||||
}
|
||||
];
|
||||
|
||||
// Validate all routes
|
||||
|
||||
const validationResult = validateRoutes(routes);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
expect(validationResult.errors.length).toEqual(0);
|
||||
|
||||
// Test route matching for different endpoints
|
||||
|
||||
// Web server (HTTPS)
|
||||
|
||||
const webServerMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
||||
expect(webServerMatch).not.toBeUndefined();
|
||||
if (webServerMatch) {
|
||||
expect(webServerMatch.action.type).toEqual('forward');
|
||||
expect(webServerMatch.action.targets[0].host).toEqual('web-server');
|
||||
}
|
||||
|
||||
// Web server (HTTP redirect via socket handler)
|
||||
|
||||
const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
expect(webRedirectMatch).not.toBeUndefined();
|
||||
if (webRedirectMatch) {
|
||||
expect(webRedirectMatch.action.type).toEqual('socket-handler');
|
||||
}
|
||||
|
||||
// API server
|
||||
const apiMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'api.example.com',
|
||||
|
||||
const apiMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'api.example.com',
|
||||
port: 443,
|
||||
path: '/v1/users'
|
||||
});
|
||||
@@ -534,10 +592,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
expect(apiMatch.action.type).toEqual('forward');
|
||||
expect(apiMatch.action.targets[0].host).toEqual('api-server');
|
||||
}
|
||||
|
||||
// WebSocket server
|
||||
const wsMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'ws.example.com',
|
||||
|
||||
const wsMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'ws.example.com',
|
||||
port: 443,
|
||||
path: '/live'
|
||||
});
|
||||
@@ -547,12 +604,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
|
||||
expect(wsMatch.action.websocket?.enabled).toBeTrue();
|
||||
}
|
||||
|
||||
// Static assets route was removed - static file serving should be handled externally
|
||||
|
||||
// Legacy system
|
||||
const legacyMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'legacy.example.com',
|
||||
|
||||
const legacyMatch = findBestMatchingRoute(routes, {
|
||||
domain: 'legacy.example.com',
|
||||
port: 443
|
||||
});
|
||||
expect(legacyMatch).not.toBeUndefined();
|
||||
@@ -565,7 +619,6 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
|
||||
// --------------------------------- Protocol Match Field Tests ---------------------------------
|
||||
|
||||
tap.test('Routes: Should accept protocol field on route match', async () => {
|
||||
// Create a route with protocol: 'http'
|
||||
const httpOnlyRoute: IRouteConfig = {
|
||||
match: {
|
||||
ports: 443,
|
||||
@@ -583,16 +636,13 @@ tap.test('Routes: Should accept protocol field on route match', async () => {
|
||||
name: 'HTTP-only Route',
|
||||
};
|
||||
|
||||
// Validate the route - protocol field should not cause errors
|
||||
const validation = validateRouteConfig(httpOnlyRoute);
|
||||
expect(validation.valid).toBeTrue();
|
||||
|
||||
// Verify the protocol field is preserved
|
||||
expect(httpOnlyRoute.match.protocol).toEqual('http');
|
||||
});
|
||||
|
||||
tap.test('Routes: Should accept protocol tcp on route match', async () => {
|
||||
// Create a route with protocol: 'tcp'
|
||||
const tcpOnlyRoute: IRouteConfig = {
|
||||
match: {
|
||||
ports: 443,
|
||||
@@ -616,28 +666,26 @@ tap.test('Routes: Should accept protocol tcp on route match', async () => {
|
||||
});
|
||||
|
||||
tap.test('Routes: Protocol field should work with terminate-and-reencrypt', async () => {
|
||||
// Create a terminate-and-reencrypt route that only accepts HTTP
|
||||
const reencryptRoute = createHttpsTerminateRoute(
|
||||
'secure.example.com',
|
||||
{ host: 'backend', port: 443 },
|
||||
{ reencrypt: true, certificate: 'auto', name: 'Reencrypt HTTP Route' }
|
||||
);
|
||||
const reencryptRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'backend', port: 443 }],
|
||||
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
|
||||
},
|
||||
name: 'Reencrypt HTTP Route'
|
||||
};
|
||||
|
||||
// Set protocol restriction to http
|
||||
reencryptRoute.match.protocol = 'http';
|
||||
|
||||
// Validate the route
|
||||
const validation = validateRouteConfig(reencryptRoute);
|
||||
expect(validation.valid).toBeTrue();
|
||||
|
||||
// Verify TLS mode
|
||||
expect(reencryptRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
// Verify protocol field is preserved
|
||||
expect(reencryptRoute.match.protocol).toEqual('http');
|
||||
});
|
||||
|
||||
tap.test('Routes: Protocol field should not affect domain/port matching', async () => {
|
||||
// Routes with and without protocol field should both match the same domain/port
|
||||
const routeWithProtocol: IRouteConfig = {
|
||||
match: {
|
||||
ports: 443,
|
||||
@@ -669,11 +717,9 @@ tap.test('Routes: Protocol field should not affect domain/port matching', async
|
||||
|
||||
const routes = [routeWithProtocol, routeWithoutProtocol];
|
||||
|
||||
// Both routes should match the domain/port (protocol is a hint for Rust-side matching)
|
||||
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 443 });
|
||||
expect(matches.length).toEqual(2);
|
||||
|
||||
// The one with higher priority should be first
|
||||
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
|
||||
expect(best).not.toBeUndefined();
|
||||
expect(best!.name).toEqual('With Protocol');
|
||||
@@ -696,11 +742,9 @@ tap.test('Routes: Protocol field preserved through route cloning', async () => {
|
||||
|
||||
const cloned = cloneRoute(original);
|
||||
|
||||
// Verify protocol is preserved in clone
|
||||
expect(cloned.match.protocol).toEqual('http');
|
||||
expect(cloned.action.tls?.mode).toEqual('terminate-and-reencrypt');
|
||||
|
||||
// Modify clone should not affect original
|
||||
cloned.match.protocol = 'tcp';
|
||||
expect(original.match.protocol).toEqual('http');
|
||||
});
|
||||
@@ -720,10 +764,9 @@ tap.test('Routes: Protocol field preserved through route merging', async () => {
|
||||
name: 'Merge Base',
|
||||
};
|
||||
|
||||
// Merge with override that changes name but not protocol
|
||||
const merged = mergeRouteConfigs(base, { name: 'Merged Route' });
|
||||
expect(merged.match.protocol).toEqual('http');
|
||||
expect(merged.name).toEqual('Merged Route');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import from individual modules to avoid naming conflicts
|
||||
import {
|
||||
// Route helpers
|
||||
createHttpRoute,
|
||||
createHttpsTerminateRoute,
|
||||
createApiRoute,
|
||||
createWebSocketRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createHttpsPassthroughRoute,
|
||||
createCompleteHttpsServer,
|
||||
createLoadBalancerRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import {
|
||||
// Route validators
|
||||
validateRouteConfig,
|
||||
validateRoutes,
|
||||
isValidDomain,
|
||||
@@ -27,7 +13,6 @@ import {
|
||||
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
|
||||
|
||||
import {
|
||||
// Route utilities
|
||||
mergeRouteConfigs,
|
||||
findMatchingRoutes,
|
||||
findBestMatchingRoute,
|
||||
@@ -39,16 +24,6 @@ import {
|
||||
cloneRoute
|
||||
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
|
||||
|
||||
import {
|
||||
// Route patterns
|
||||
createApiGatewayRoute,
|
||||
createWebSocketRoute as createWebSocketPattern,
|
||||
createLoadBalancerRoute as createLbPattern,
|
||||
addRateLimiting,
|
||||
addBasicAuth,
|
||||
addJwtAuth
|
||||
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
|
||||
import type {
|
||||
IRouteConfig,
|
||||
IRouteMatch,
|
||||
@@ -84,7 +59,7 @@ tap.test('Route Validation - isValidPort', async () => {
|
||||
expect(isValidPort(443)).toBeTrue();
|
||||
expect(isValidPort(8080)).toBeTrue();
|
||||
expect(isValidPort([80, 443])).toBeTrue();
|
||||
|
||||
|
||||
// Invalid ports
|
||||
expect(isValidPort(0)).toBeFalse();
|
||||
expect(isValidPort(65536)).toBeFalse();
|
||||
@@ -101,7 +76,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
|
||||
const validResult = validateRouteMatch(validMatch);
|
||||
expect(validResult.valid).toBeTrue();
|
||||
expect(validResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Invalid match configuration (invalid domain)
|
||||
const invalidMatch: IRouteMatch = {
|
||||
ports: 80,
|
||||
@@ -111,7 +86,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
|
||||
expect(invalidResult.valid).toBeFalse();
|
||||
expect(invalidResult.errors.length).toBeGreaterThan(0);
|
||||
expect(invalidResult.errors[0]).toInclude('Invalid domain');
|
||||
|
||||
|
||||
// Invalid match configuration (invalid port)
|
||||
const invalidPortMatch: IRouteMatch = {
|
||||
ports: 0,
|
||||
@@ -121,7 +96,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
|
||||
expect(invalidPortResult.valid).toBeFalse();
|
||||
expect(invalidPortResult.errors.length).toBeGreaterThan(0);
|
||||
expect(invalidPortResult.errors[0]).toInclude('Invalid port');
|
||||
|
||||
|
||||
// Test path validation
|
||||
const invalidPathMatch: IRouteMatch = {
|
||||
ports: 80,
|
||||
@@ -146,7 +121,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
const validForwardResult = validateRouteAction(validForwardAction);
|
||||
expect(validForwardResult.valid).toBeTrue();
|
||||
expect(validForwardResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Valid socket-handler action
|
||||
const validSocketAction: IRouteAction = {
|
||||
type: 'socket-handler',
|
||||
@@ -157,7 +132,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
const validSocketResult = validateRouteAction(validSocketAction);
|
||||
expect(validSocketResult.valid).toBeTrue();
|
||||
expect(validSocketResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Invalid action (missing targets)
|
||||
const invalidAction: IRouteAction = {
|
||||
type: 'forward'
|
||||
@@ -166,7 +141,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
expect(invalidResult.valid).toBeFalse();
|
||||
expect(invalidResult.errors.length).toBeGreaterThan(0);
|
||||
expect(invalidResult.errors[0]).toInclude('Targets array is required');
|
||||
|
||||
|
||||
// Invalid action (missing socket handler)
|
||||
const invalidSocketAction: IRouteAction = {
|
||||
type: 'socket-handler'
|
||||
@@ -179,11 +154,15 @@ tap.test('Route Validation - validateRouteAction', async () => {
|
||||
|
||||
tap.test('Route Validation - validateRouteConfig', async () => {
|
||||
// Valid route config
|
||||
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const validRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
const validResult = validateRouteConfig(validRoute);
|
||||
expect(validResult.valid).toBeTrue();
|
||||
expect(validResult.errors.length).toEqual(0);
|
||||
|
||||
|
||||
// Invalid route config (missing targets)
|
||||
const invalidRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -203,7 +182,11 @@ tap.test('Route Validation - validateRouteConfig', async () => {
|
||||
tap.test('Route Validation - validateRoutes', async () => {
|
||||
// Create valid and invalid routes
|
||||
const routes = [
|
||||
createHttpRoute('example.com', { host: 'localhost', port: 3000 }),
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
} as IRouteConfig,
|
||||
{
|
||||
match: {
|
||||
domains: 'invalid..domain',
|
||||
@@ -217,9 +200,13 @@ tap.test('Route Validation - validateRoutes', async () => {
|
||||
}
|
||||
}
|
||||
} as IRouteConfig,
|
||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 })
|
||||
{
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Terminate Route for secure.example.com',
|
||||
} as IRouteConfig
|
||||
];
|
||||
|
||||
|
||||
const result = validateRoutes(routes);
|
||||
expect(result.valid).toBeFalse();
|
||||
expect(result.errors.length).toEqual(1);
|
||||
@@ -230,13 +217,13 @@ tap.test('Route Validation - validateRoutes', async () => {
|
||||
|
||||
tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
||||
// Forward action
|
||||
const forwardRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const forwardRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
expect(hasRequiredPropertiesForAction(forwardRoute, 'forward')).toBeTrue();
|
||||
|
||||
// Socket handler action (redirect functionality)
|
||||
const redirectRoute = createHttpToHttpsRedirect('example.com');
|
||||
expect(hasRequiredPropertiesForAction(redirectRoute, 'socket-handler')).toBeTrue();
|
||||
|
||||
|
||||
// Socket handler action
|
||||
const socketRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -252,7 +239,7 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
||||
name: 'Socket Handler Route'
|
||||
};
|
||||
expect(hasRequiredPropertiesForAction(socketRoute, 'socket-handler')).toBeTrue();
|
||||
|
||||
|
||||
// Missing required properties
|
||||
const invalidForwardRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -269,9 +256,13 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
|
||||
|
||||
tap.test('Route Validation - assertValidRoute', async () => {
|
||||
// Valid route
|
||||
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const validRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
expect(() => assertValidRoute(validRoute)).not.toThrow();
|
||||
|
||||
|
||||
// Invalid route
|
||||
const invalidRoute: IRouteConfig = {
|
||||
match: {
|
||||
@@ -290,8 +281,12 @@ tap.test('Route Validation - assertValidRoute', async () => {
|
||||
|
||||
tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
// Base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const baseRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
|
||||
// Override with different name and port
|
||||
const overrideRoute: Partial<IRouteConfig> = {
|
||||
name: 'Merged Route',
|
||||
@@ -299,16 +294,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
ports: 8080
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Merge configs
|
||||
const mergedRoute = mergeRouteConfigs(baseRoute, overrideRoute);
|
||||
|
||||
|
||||
// Check merged properties
|
||||
expect(mergedRoute.name).toEqual('Merged Route');
|
||||
expect(mergedRoute.match.ports).toEqual(8080);
|
||||
expect(mergedRoute.match.domains).toEqual('example.com');
|
||||
expect(mergedRoute.action.type).toEqual('forward');
|
||||
|
||||
|
||||
// Test merging action properties
|
||||
const actionOverride: Partial<IRouteConfig> = {
|
||||
action: {
|
||||
@@ -319,11 +314,11 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
|
||||
expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
|
||||
expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
|
||||
|
||||
|
||||
// Test replacing action with socket handler
|
||||
const typeChangeOverride: Partial<IRouteConfig> = {
|
||||
action: {
|
||||
@@ -336,7 +331,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
|
||||
expect(typeChangedRoute.action.type).toEqual('socket-handler');
|
||||
expect(typeChangedRoute.action.socketHandler).toBeDefined();
|
||||
@@ -345,37 +340,53 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
|
||||
|
||||
tap.test('Route Matching - routeMatchesDomain', async () => {
|
||||
// Create route with wildcard domain
|
||||
const wildcardRoute = createHttpRoute('*.example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const wildcardRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: '*.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for *.example.com',
|
||||
};
|
||||
|
||||
// Create route with exact domain
|
||||
const exactRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const exactRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
|
||||
// Create route with multiple domains
|
||||
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 });
|
||||
|
||||
const multiDomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: ['example.com', 'example.org'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com,example.org',
|
||||
};
|
||||
|
||||
// Test wildcard domain matching
|
||||
expect(routeMatchesDomain(wildcardRoute, 'sub.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardRoute, 'another.example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(wildcardRoute, 'example.com')).toBeFalse();
|
||||
expect(routeMatchesDomain(wildcardRoute, 'example.org')).toBeFalse();
|
||||
|
||||
|
||||
// Test exact domain matching
|
||||
expect(routeMatchesDomain(exactRoute, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(exactRoute, 'sub.example.com')).toBeFalse();
|
||||
|
||||
|
||||
// Test multiple domains matching
|
||||
expect(routeMatchesDomain(multiDomainRoute, 'example.com')).toBeTrue();
|
||||
expect(routeMatchesDomain(multiDomainRoute, 'example.org')).toBeTrue();
|
||||
expect(routeMatchesDomain(multiDomainRoute, 'example.net')).toBeFalse();
|
||||
|
||||
|
||||
// Test case insensitivity
|
||||
expect(routeMatchesDomain(exactRoute, 'Example.Com')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
// Create routes with different port configurations
|
||||
const singlePortRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
const singlePortRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
|
||||
const multiPortRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -389,7 +400,7 @@ tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const portRangeRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -403,16 +414,16 @@ tap.test('Route Matching - routeMatchesPort', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test single port matching
|
||||
expect(routeMatchesPort(singlePortRoute, 80)).toBeTrue();
|
||||
expect(routeMatchesPort(singlePortRoute, 443)).toBeFalse();
|
||||
|
||||
|
||||
// Test multi-port matching
|
||||
expect(routeMatchesPort(multiPortRoute, 80)).toBeTrue();
|
||||
expect(routeMatchesPort(multiPortRoute, 8080)).toBeTrue();
|
||||
expect(routeMatchesPort(multiPortRoute, 3000)).toBeFalse();
|
||||
|
||||
|
||||
// Test port range matching
|
||||
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
|
||||
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
|
||||
@@ -437,11 +448,11 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test prefix matching with wildcard (not trailing slash)
|
||||
const prefixPathRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
domains: 'example.com',
|
||||
ports: 80,
|
||||
path: '/api/*'
|
||||
},
|
||||
@@ -453,7 +464,7 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const wildcardPathRoute: IRouteConfig = {
|
||||
match: {
|
||||
domains: 'example.com',
|
||||
@@ -468,17 +479,17 @@ tap.test('Route Matching - routeMatchesPath', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test exact path matching
|
||||
expect(routeMatchesPath(exactPathRoute, '/api')).toBeTrue();
|
||||
expect(routeMatchesPath(exactPathRoute, '/api/users')).toBeFalse();
|
||||
expect(routeMatchesPath(exactPathRoute, '/app')).toBeFalse();
|
||||
|
||||
|
||||
// Test prefix path matching with wildcard
|
||||
expect(routeMatchesPath(prefixPathRoute, '/api/')).toBeFalse(); // Wildcard requires content after /api/
|
||||
expect(routeMatchesPath(prefixPathRoute, '/api/users')).toBeTrue();
|
||||
expect(routeMatchesPath(prefixPathRoute, '/app/')).toBeFalse();
|
||||
|
||||
|
||||
// Test wildcard path matching
|
||||
expect(routeMatchesPath(wildcardPathRoute, '/api/users')).toBeTrue();
|
||||
expect(routeMatchesPath(wildcardPathRoute, '/api/products')).toBeTrue();
|
||||
@@ -504,30 +515,34 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Test header matching
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'value'
|
||||
})).toBeTrue();
|
||||
|
||||
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'value',
|
||||
'Extra-Header': 'something'
|
||||
})).toBeTrue();
|
||||
|
||||
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'application/json'
|
||||
})).toBeFalse();
|
||||
|
||||
|
||||
expect(routeMatchesHeaders(headerRoute, {
|
||||
'Content-Type': 'text/html',
|
||||
'X-Custom-Header': 'value'
|
||||
})).toBeFalse();
|
||||
|
||||
|
||||
// Route without header matching should match any headers
|
||||
const noHeaderRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const noHeaderRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
expect(routeMatchesHeaders(noHeaderRoute, {
|
||||
'Content-Type': 'application/json'
|
||||
})).toBeTrue();
|
||||
@@ -536,78 +551,118 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
|
||||
tap.test('Route Finding - findMatchingRoutes', async () => {
|
||||
// Create multiple routes
|
||||
const routes: IRouteConfig[] = [
|
||||
createHttpRoute('example.com', { host: 'localhost', port: 3000 }),
|
||||
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 }),
|
||||
createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3002 }),
|
||||
createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3003 })
|
||||
{
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Route for secure.example.com',
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'API Route for api.example.com',
|
||||
},
|
||||
{
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3003 }], tls: { mode: 'terminate', certificate: 'auto' }, websocket: { enabled: true } },
|
||||
name: 'WebSocket Route for ws.example.com',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// Set priorities
|
||||
routes[0].priority = 10;
|
||||
routes[1].priority = 20;
|
||||
routes[2].priority = 30;
|
||||
routes[3].priority = 40;
|
||||
|
||||
|
||||
// Find routes for different criteria
|
||||
const httpMatches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
|
||||
expect(httpMatches.length).toEqual(1);
|
||||
expect(httpMatches[0].name).toInclude('HTTP Route');
|
||||
|
||||
|
||||
const httpsMatches = findMatchingRoutes(routes, { domain: 'secure.example.com', port: 443 });
|
||||
expect(httpsMatches.length).toEqual(1);
|
||||
expect(httpsMatches[0].name).toInclude('HTTPS Route');
|
||||
|
||||
|
||||
const apiMatches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/v1/users' });
|
||||
expect(apiMatches.length).toEqual(1);
|
||||
expect(apiMatches[0].name).toInclude('API Route');
|
||||
|
||||
|
||||
const wsMatches = findMatchingRoutes(routes, { domain: 'ws.example.com', path: '/socket' });
|
||||
expect(wsMatches.length).toEqual(1);
|
||||
expect(wsMatches[0].name).toInclude('WebSocket Route');
|
||||
|
||||
|
||||
// Test finding multiple routes that match same criteria
|
||||
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route1.priority = 10;
|
||||
|
||||
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 });
|
||||
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route2.priority = 20;
|
||||
route2.match.path = '/api';
|
||||
|
||||
|
||||
const multiMatchRoutes = [route1, route2];
|
||||
|
||||
|
||||
const multiMatches = findMatchingRoutes(multiMatchRoutes, { domain: 'example.com', port: 80 });
|
||||
expect(multiMatches.length).toEqual(2);
|
||||
expect(multiMatches[0].priority).toEqual(20); // Higher priority should be first
|
||||
expect(multiMatches[1].priority).toEqual(10);
|
||||
|
||||
|
||||
// Test disabled routes
|
||||
const disabledRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const disabledRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
disabledRoute.enabled = false;
|
||||
|
||||
|
||||
const enabledRoutes = findMatchingRoutes([disabledRoute], { domain: 'example.com', port: 80 });
|
||||
expect(enabledRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Route Finding - findBestMatchingRoute', async () => {
|
||||
// Create multiple routes with different priorities
|
||||
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const route1: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route1.priority = 10;
|
||||
|
||||
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 });
|
||||
|
||||
const route2: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route2.priority = 20;
|
||||
route2.match.path = '/api';
|
||||
|
||||
const route3 = createHttpRoute('example.com', { host: 'localhost', port: 3002 });
|
||||
|
||||
const route3: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
route3.priority = 30;
|
||||
route3.match.path = '/api/users';
|
||||
|
||||
|
||||
const routes = [route1, route2, route3];
|
||||
|
||||
|
||||
// Find best route for different criteria
|
||||
const bestGeneral = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
|
||||
expect(bestGeneral).not.toBeUndefined();
|
||||
expect(bestGeneral?.priority).toEqual(30);
|
||||
|
||||
|
||||
// Test when no routes match
|
||||
const noMatch = findBestMatchingRoute(routes, { domain: 'unknown.com', port: 80 });
|
||||
expect(noMatch).toBeUndefined();
|
||||
@@ -615,389 +670,54 @@ tap.test('Route Finding - findBestMatchingRoute', async () => {
|
||||
|
||||
tap.test('Route Utilities - generateRouteId', async () => {
|
||||
// Test ID generation for different route types
|
||||
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
const httpRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com',
|
||||
};
|
||||
const httpId = generateRouteId(httpRoute);
|
||||
expect(httpId).toInclude('example-com');
|
||||
expect(httpId).toInclude('80');
|
||||
expect(httpId).toInclude('forward');
|
||||
|
||||
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 });
|
||||
|
||||
const httpsRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'secure.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'HTTPS Terminate Route for secure.example.com',
|
||||
};
|
||||
const httpsId = generateRouteId(httpsRoute);
|
||||
expect(httpsId).toInclude('secure-example-com');
|
||||
expect(httpsId).toInclude('443');
|
||||
expect(httpsId).toInclude('forward');
|
||||
|
||||
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 });
|
||||
|
||||
const multiDomainRoute: IRouteConfig = {
|
||||
match: { ports: 80, domains: ['example.com', 'example.org'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
|
||||
name: 'HTTP Route for example.com,example.org',
|
||||
};
|
||||
const multiDomainId = generateRouteId(multiDomainRoute);
|
||||
expect(multiDomainId).toInclude('example-com-example-org');
|
||||
});
|
||||
|
||||
tap.test('Route Utilities - cloneRoute', async () => {
|
||||
// Create a route and clone it
|
||||
const originalRoute = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
certificate: 'auto',
|
||||
name: 'Original Route'
|
||||
});
|
||||
|
||||
const originalRoute: IRouteConfig = {
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } },
|
||||
name: 'Original Route',
|
||||
};
|
||||
|
||||
const clonedRoute = cloneRoute(originalRoute);
|
||||
|
||||
|
||||
// Check that the values are identical
|
||||
expect(clonedRoute.name).toEqual(originalRoute.name);
|
||||
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
|
||||
expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
|
||||
expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
|
||||
|
||||
|
||||
// Modify the clone and check that the original is unchanged
|
||||
clonedRoute.name = 'Modified Clone';
|
||||
expect(originalRoute.name).toEqual('Original Route');
|
||||
});
|
||||
|
||||
// --------------------------------- Route Helper Tests ---------------------------------
|
||||
|
||||
tap.test('Route Helpers - createHttpRoute', async () => {
|
||||
const route = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(80);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.targets?.[0]?.host).toEqual('localhost');
|
||||
expect(route.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createHttpsTerminateRoute', async () => {
|
||||
const route = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
|
||||
certificate: 'auto'
|
||||
});
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
expect(route.action.tls.certificate).toEqual('auto');
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createHttpToHttpsRedirect', async () => {
|
||||
const route = createHttpToHttpsRedirect('example.com');
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(80);
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createHttpsPassthroughRoute', async () => {
|
||||
const route = createHttpsPassthroughRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
expect(route.match.domains).toEqual('example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('passthrough');
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createCompleteHttpsServer', async () => {
|
||||
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 3000 }, {
|
||||
certificate: 'auto'
|
||||
});
|
||||
|
||||
expect(routes.length).toEqual(2);
|
||||
|
||||
// HTTPS route
|
||||
expect(routes[0].match.domains).toEqual('example.com');
|
||||
expect(routes[0].match.ports).toEqual(443);
|
||||
expect(routes[0].action.type).toEqual('forward');
|
||||
expect(routes[0].action.tls.mode).toEqual('terminate');
|
||||
|
||||
// HTTP redirect route
|
||||
expect(routes[1].match.domains).toEqual('example.com');
|
||||
expect(routes[1].match.ports).toEqual(80);
|
||||
expect(routes[1].action.type).toEqual('socket-handler');
|
||||
|
||||
const validation1 = validateRouteConfig(routes[0]);
|
||||
const validation2 = validateRouteConfig(routes[1]);
|
||||
expect(validation1.valid).toBeTrue();
|
||||
expect(validation2.valid).toBeTrue();
|
||||
});
|
||||
|
||||
// createStaticFileRoute has been removed - static file serving should be handled by
|
||||
// external servers (nginx/apache) behind the proxy
|
||||
|
||||
tap.test('Route Helpers - createApiRoute', async () => {
|
||||
const route = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
addCorsHeaders: true
|
||||
});
|
||||
|
||||
expect(route.match.domains).toEqual('api.example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.match.path).toEqual('/v1/*');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
|
||||
// Check CORS headers if they exist
|
||||
if (route.headers && route.headers.response) {
|
||||
expect(route.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
||||
}
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createWebSocketRoute', async () => {
|
||||
const route = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3000 }, {
|
||||
useTls: true,
|
||||
certificate: 'auto',
|
||||
pingInterval: 15000
|
||||
});
|
||||
|
||||
expect(route.match.domains).toEqual('ws.example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.match.path).toEqual('/socket');
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
|
||||
// Check websocket configuration if it exists
|
||||
if (route.action.websocket) {
|
||||
expect(route.action.websocket.enabled).toBeTrue();
|
||||
expect(route.action.websocket.pingInterval).toEqual(15000);
|
||||
}
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Helpers - createLoadBalancerRoute', async () => {
|
||||
const route = createLoadBalancerRoute(
|
||||
'loadbalancer.example.com',
|
||||
['server1.local', 'server2.local', 'server3.local'],
|
||||
8080,
|
||||
{
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(route.match.domains).toEqual('loadbalancer.example.com');
|
||||
expect(route.match.ports).toEqual(443);
|
||||
expect(route.action.type).toEqual('forward');
|
||||
expect(route.action.targets).toBeDefined();
|
||||
if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
|
||||
expect((route.action.targets[0].host as string[]).length).toEqual(3);
|
||||
}
|
||||
expect(route.action.targets?.[0]?.port).toEqual(8080);
|
||||
expect(route.action.tls.mode).toEqual('terminate');
|
||||
|
||||
const validationResult = validateRouteConfig(route);
|
||||
expect(validationResult.valid).toBeTrue();
|
||||
});
|
||||
|
||||
// --------------------------------- Route Pattern Tests ---------------------------------
|
||||
|
||||
tap.test('Route Patterns - createApiGatewayRoute', async () => {
|
||||
// Create API Gateway route
|
||||
const apiGatewayRoute = createApiGatewayRoute(
|
||||
'api.example.com',
|
||||
'/v1',
|
||||
{ host: 'localhost', port: 3000 },
|
||||
{
|
||||
useTls: true,
|
||||
addCorsHeaders: true
|
||||
}
|
||||
);
|
||||
|
||||
// Validate route configuration
|
||||
expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
|
||||
expect(apiGatewayRoute.match.path).toInclude('/v1');
|
||||
expect(apiGatewayRoute.action.type).toEqual('forward');
|
||||
expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check TLS configuration
|
||||
if (apiGatewayRoute.action.tls) {
|
||||
expect(apiGatewayRoute.action.tls.mode).toEqual('terminate');
|
||||
}
|
||||
|
||||
// Check CORS headers
|
||||
if (apiGatewayRoute.headers && apiGatewayRoute.headers.response) {
|
||||
expect(apiGatewayRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
|
||||
}
|
||||
|
||||
const result = validateRouteConfig(apiGatewayRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
// createStaticFileServerRoute has been removed - static file serving should be handled by
|
||||
// external servers (nginx/apache) behind the proxy
|
||||
|
||||
tap.test('Route Patterns - createWebSocketPattern', async () => {
|
||||
// Create WebSocket route pattern
|
||||
const wsRoute = createWebSocketPattern(
|
||||
'ws.example.com',
|
||||
{ host: 'localhost', port: 3000 },
|
||||
{
|
||||
useTls: true,
|
||||
path: '/socket',
|
||||
pingInterval: 10000
|
||||
}
|
||||
);
|
||||
|
||||
// Validate route configuration
|
||||
expect(wsRoute.match.domains).toEqual('ws.example.com');
|
||||
expect(wsRoute.match.path).toEqual('/socket');
|
||||
expect(wsRoute.action.type).toEqual('forward');
|
||||
expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
|
||||
|
||||
// Check TLS configuration
|
||||
if (wsRoute.action.tls) {
|
||||
expect(wsRoute.action.tls.mode).toEqual('terminate');
|
||||
}
|
||||
|
||||
// Check websocket configuration if it exists
|
||||
if (wsRoute.action.websocket) {
|
||||
expect(wsRoute.action.websocket.enabled).toBeTrue();
|
||||
expect(wsRoute.action.websocket.pingInterval).toEqual(10000);
|
||||
}
|
||||
|
||||
const result = validateRouteConfig(wsRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
|
||||
// Create load balancer route pattern with missing algorithm as it might not be implemented yet
|
||||
try {
|
||||
const lbRoute = createLbPattern(
|
||||
'lb.example.com',
|
||||
[
|
||||
{ host: 'server1.local', port: 8080 },
|
||||
{ host: 'server2.local', port: 8080 },
|
||||
{ host: 'server3.local', port: 8080 }
|
||||
],
|
||||
{
|
||||
useTls: true
|
||||
}
|
||||
);
|
||||
|
||||
// Validate route configuration
|
||||
expect(lbRoute.match.domains).toEqual('lb.example.com');
|
||||
expect(lbRoute.action.type).toEqual('forward');
|
||||
|
||||
// Check target hosts
|
||||
if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
|
||||
expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
|
||||
}
|
||||
|
||||
// Check TLS configuration
|
||||
if (lbRoute.action.tls) {
|
||||
expect(lbRoute.action.tls.mode).toEqual('terminate');
|
||||
}
|
||||
|
||||
const result = validateRouteConfig(lbRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
} catch (error) {
|
||||
// If the pattern is not implemented yet, skip this test
|
||||
console.log('Load balancer pattern might not be fully implemented yet');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Route Security - addRateLimiting', async () => {
|
||||
// Create base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
// Add rate limiting
|
||||
const secureRoute = addRateLimiting(baseRoute, {
|
||||
maxRequests: 100,
|
||||
window: 60, // 1 minute
|
||||
keyBy: 'ip'
|
||||
});
|
||||
|
||||
// Check if rate limiting is applied
|
||||
if (secureRoute.security) {
|
||||
expect(secureRoute.security.rateLimit?.enabled).toBeTrue();
|
||||
expect(secureRoute.security.rateLimit?.maxRequests).toEqual(100);
|
||||
expect(secureRoute.security.rateLimit?.window).toEqual(60);
|
||||
expect(secureRoute.security.rateLimit?.keyBy).toEqual('ip');
|
||||
} else {
|
||||
// Skip this test if security features are not implemented yet
|
||||
console.log('Security features not implemented yet in route configuration');
|
||||
}
|
||||
|
||||
// Just check that the route itself is valid
|
||||
const result = validateRouteConfig(secureRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Security - addBasicAuth', async () => {
|
||||
// Create base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
// Add basic authentication
|
||||
const authRoute = addBasicAuth(baseRoute, {
|
||||
users: [
|
||||
{ username: 'admin', password: 'secret' },
|
||||
{ username: 'user', password: 'password' }
|
||||
],
|
||||
realm: 'Protected Area',
|
||||
excludePaths: ['/public']
|
||||
});
|
||||
|
||||
// Check if basic auth is applied
|
||||
if (authRoute.security) {
|
||||
expect(authRoute.security.basicAuth?.enabled).toBeTrue();
|
||||
expect(authRoute.security.basicAuth?.users.length).toEqual(2);
|
||||
expect(authRoute.security.basicAuth?.realm).toEqual('Protected Area');
|
||||
expect(authRoute.security.basicAuth?.excludePaths).toInclude('/public');
|
||||
} else {
|
||||
// Skip this test if security features are not implemented yet
|
||||
console.log('Security features not implemented yet in route configuration');
|
||||
}
|
||||
|
||||
// Check that the route itself is valid
|
||||
const result = validateRouteConfig(authRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Route Security - addJwtAuth', async () => {
|
||||
// Create base route
|
||||
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
|
||||
|
||||
// Add JWT authentication
|
||||
const jwtRoute = addJwtAuth(baseRoute, {
|
||||
secret: 'your-jwt-secret-key',
|
||||
algorithm: 'HS256',
|
||||
issuer: 'auth.example.com',
|
||||
audience: 'api.example.com',
|
||||
expiresIn: 3600
|
||||
});
|
||||
|
||||
// Check if JWT auth is applied
|
||||
if (jwtRoute.security) {
|
||||
expect(jwtRoute.security.jwtAuth?.enabled).toBeTrue();
|
||||
expect(jwtRoute.security.jwtAuth?.secret).toEqual('your-jwt-secret-key');
|
||||
expect(jwtRoute.security.jwtAuth?.algorithm).toEqual('HS256');
|
||||
expect(jwtRoute.security.jwtAuth?.issuer).toEqual('auth.example.com');
|
||||
expect(jwtRoute.security.jwtAuth?.audience).toEqual('api.example.com');
|
||||
expect(jwtRoute.security.jwtAuth?.expiresIn).toEqual(3600);
|
||||
} else {
|
||||
// Skip this test if security features are not implemented yet
|
||||
console.log('Security features not implemented yet in route configuration');
|
||||
}
|
||||
|
||||
// Check that the route itself is valid
|
||||
const result = validateRouteConfig(jwtRoute);
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '26.3.0',
|
||||
version: '27.0.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.'
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
* SmartProxy Route Utilities
|
||||
*
|
||||
* This file exports all route-related utilities for the SmartProxy module,
|
||||
* including helpers, validators, utilities, and patterns for working with routes.
|
||||
* including validators, utilities, and socket handlers for working with routes.
|
||||
*/
|
||||
|
||||
// Export route helpers for creating route configurations
|
||||
export * from './route-helpers.js';
|
||||
|
||||
// Export route validator (class-based and functional API)
|
||||
export * from './route-validator.js';
|
||||
|
||||
@@ -20,10 +17,5 @@ export { generateDefaultCertificate } from './default-cert-generator.js';
|
||||
// Export concurrency semaphore
|
||||
export { ConcurrencySemaphore } from './concurrency-semaphore.js';
|
||||
|
||||
// Export additional functions from route-helpers that weren't already exported
|
||||
export {
|
||||
createApiGatewayRoute,
|
||||
addRateLimiting,
|
||||
addBasicAuth,
|
||||
addJwtAuth
|
||||
} from './route-helpers.js';
|
||||
// Export socket handlers
|
||||
export { SocketHandlers } from './socket-handlers.js';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Route Helper Functions
|
||||
*
|
||||
* This file re-exports all route helper functions for backwards compatibility.
|
||||
* The actual implementations have been split into focused modules in the route-helpers/ directory.
|
||||
*
|
||||
* @see ./route-helpers/index.ts for the modular exports
|
||||
*/
|
||||
|
||||
// Re-export everything from the modular helpers
|
||||
export * from './route-helpers/index.js';
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* API Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating API route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
import { mergeRouteConfigs } from '../route-utils.js';
|
||||
import { createHttpRoute } from './http-helpers.js';
|
||||
import { createHttpsTerminateRoute } from './https-helpers.js';
|
||||
|
||||
/**
|
||||
* Create an API route configuration
|
||||
* @param domains Domain(s) to match
|
||||
* @param apiPath API base path (e.g., "/api")
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createApiRoute(
|
||||
domains: string | string[],
|
||||
apiPath: string,
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
addCorsHeaders?: boolean;
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Normalize API path
|
||||
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
|
||||
const pathWithWildcard = normalizedPath.endsWith('/')
|
||||
? `${normalizedPath}*`
|
||||
: `${normalizedPath}/*`;
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.useTls
|
||||
? (options.httpsPort || 443)
|
||||
: (options.httpPort || 80),
|
||||
domains,
|
||||
path: pathWithWildcard
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Add TLS configuration if using HTTPS
|
||||
if (options.useTls) {
|
||||
action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
// Add CORS headers if requested
|
||||
const headers: Record<string, Record<string, string>> = {};
|
||||
if (options.addCorsHeaders) {
|
||||
headers.response = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
name: options.name || `API Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
priority: options.priority || 100, // Higher priority for specific path matches
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API Gateway route pattern
|
||||
* @param domains Domain(s) to match
|
||||
* @param apiBasePath Base path for API endpoints (e.g., '/api')
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns API route configuration
|
||||
*/
|
||||
export function createApiGatewayRoute(
|
||||
domains: string | string[],
|
||||
apiBasePath: string,
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
addCorsHeaders?: boolean;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Normalize apiBasePath to ensure it starts with / and doesn't end with /
|
||||
const normalizedPath = apiBasePath.startsWith('/')
|
||||
? apiBasePath
|
||||
: `/${apiBasePath}`;
|
||||
|
||||
// Add wildcard to path to match all API endpoints
|
||||
const apiPath = normalizedPath.endsWith('/')
|
||||
? `${normalizedPath}*`
|
||||
: `${normalizedPath}/*`;
|
||||
|
||||
// Create base route
|
||||
const baseRoute = options.useTls
|
||||
? createHttpsTerminateRoute(domains, target, {
|
||||
certificate: options.certificate || 'auto'
|
||||
})
|
||||
: createHttpRoute(domains, target);
|
||||
|
||||
// Add API-specific configurations
|
||||
const apiRoute: Partial<IRouteConfig> = {
|
||||
match: {
|
||||
...baseRoute.match,
|
||||
path: apiPath
|
||||
},
|
||||
name: options.name || `API Gateway: ${apiPath} -> ${Array.isArray(target.host) ? target.host.join(', ') : target.host}:${target.port}`,
|
||||
priority: options.priority || 100 // Higher priority for specific path matching
|
||||
};
|
||||
|
||||
// Add CORS headers if requested
|
||||
if (options.addCorsHeaders) {
|
||||
apiRoute.headers = {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return mergeRouteConfigs(baseRoute, apiRoute);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Dynamic Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating dynamic routes
|
||||
* with context-based host and port mapping.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create a helper function that applies a port offset
|
||||
* @param offset The offset to apply to the matched port
|
||||
* @returns A function that adds the offset to the matched port
|
||||
*/
|
||||
export function createPortOffset(offset: number): (context: IRouteContext) => number {
|
||||
return (context: IRouteContext) => context.port + offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a port mapping route with context-based port function
|
||||
* @param options Port mapping route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createPortMappingRoute(options: {
|
||||
sourcePortRange: TPortRange;
|
||||
targetHost: string | string[] | ((context: IRouteContext) => string | string[]);
|
||||
portMapper: (context: IRouteContext) => number;
|
||||
name?: string;
|
||||
domains?: string | string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.sourcePortRange,
|
||||
domains: options.domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: options.targetHost,
|
||||
port: options.portMapper
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `Port Mapping Route for ${options.domains || 'all domains'}`,
|
||||
priority: options.priority,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple offset port mapping route
|
||||
* @param options Offset port mapping route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createOffsetPortMappingRoute(options: {
|
||||
ports: TPortRange;
|
||||
targetHost: string | string[];
|
||||
offset: number;
|
||||
name?: string;
|
||||
domains?: string | string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
const { ports, targetHost, offset, name, domains, priority, ...rest } = options;
|
||||
return createPortMappingRoute({
|
||||
sourcePortRange: ports,
|
||||
targetHost,
|
||||
portMapper: (context) => context.port + offset,
|
||||
name: name || `Offset Mapping (${offset > 0 ? '+' : ''}${offset}) for ${domains || 'all domains'}`,
|
||||
domains,
|
||||
priority,
|
||||
...rest
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dynamic route with context-based host and port mapping
|
||||
* @param options Dynamic route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createDynamicRoute(options: {
|
||||
ports: TPortRange;
|
||||
targetHost: (context: IRouteContext) => string | string[];
|
||||
portMapper: (context: IRouteContext) => number;
|
||||
name?: string;
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
clientIp?: string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.ports,
|
||||
domains: options.domains,
|
||||
path: options.path,
|
||||
clientIp: options.clientIp
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: options.targetHost,
|
||||
port: options.portMapper
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `Dynamic Route for ${options.domains || 'all domains'}`,
|
||||
priority: options.priority,
|
||||
...options
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* HTTP Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating HTTP route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create an HTTP-only route configuration
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpRoute(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.match?.ports || 80,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTP Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* HTTPS Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating HTTPS route configurations
|
||||
* including TLS termination and passthrough routes.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
import { SocketHandlers } from './socket-handlers.js';
|
||||
|
||||
/**
|
||||
* Create an HTTPS route with TLS termination
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpsTerminateRoute(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
reencrypt?: boolean;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.httpsPort || 443,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target],
|
||||
tls: {
|
||||
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
}
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTPS Route for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP to HTTPS redirect route
|
||||
* @param domains Domain(s) to match
|
||||
* @param httpsPort HTTPS port to redirect to (default: 443)
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpToHttpsRedirect(
|
||||
domains: string | string[],
|
||||
httpsPort: number = 443,
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.match?.ports || 80,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'socket-handler',
|
||||
socketHandler: SocketHandlers.httpRedirect(`https://{domain}:${httpsPort}{path}`, 301)
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTPS passthrough route (SNI-based forwarding without TLS termination)
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createHttpsPassthroughRoute(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: Partial<IRouteConfig> = {}
|
||||
): IRouteConfig {
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.match?.ports || 443,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target],
|
||||
tls: {
|
||||
mode: 'passthrough'
|
||||
}
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `HTTPS Passthrough for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete HTTPS server with HTTP to HTTPS redirects
|
||||
* @param domains Domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional configuration options
|
||||
* @returns Array of two route configurations (HTTPS and HTTP redirect)
|
||||
*/
|
||||
export function createCompleteHttpsServer(
|
||||
domains: string | string[],
|
||||
target: { host: string | string[]; port: number },
|
||||
options: {
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
reencrypt?: boolean;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
} = {}
|
||||
): IRouteConfig[] {
|
||||
// Create the HTTPS route
|
||||
const httpsRoute = createHttpsTerminateRoute(domains, target, options);
|
||||
|
||||
// Create the HTTP redirect route
|
||||
const httpRedirectRoute = createHttpToHttpsRedirect(
|
||||
domains,
|
||||
// Extract the HTTPS port from the HTTPS route - ensure it's a number
|
||||
typeof options.httpsPort === 'number' ? options.httpsPort :
|
||||
Array.isArray(options.httpsPort) ? options.httpsPort[0] : 443,
|
||||
{
|
||||
// Set the HTTP port
|
||||
match: {
|
||||
ports: options.httpPort || 80,
|
||||
domains
|
||||
},
|
||||
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains}`
|
||||
}
|
||||
);
|
||||
|
||||
return [httpsRoute, httpRedirectRoute];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating route configurations for common scenarios.
|
||||
* These functions aim to simplify the creation of route configurations for typical use cases.
|
||||
*
|
||||
* This barrel file re-exports all helper functions for backwards compatibility.
|
||||
*/
|
||||
|
||||
// HTTP helpers
|
||||
export { createHttpRoute } from './http-helpers.js';
|
||||
|
||||
// HTTPS helpers
|
||||
export {
|
||||
createHttpsTerminateRoute,
|
||||
createHttpToHttpsRedirect,
|
||||
createHttpsPassthroughRoute,
|
||||
createCompleteHttpsServer
|
||||
} from './https-helpers.js';
|
||||
|
||||
// WebSocket helpers
|
||||
export { createWebSocketRoute } from './websocket-helpers.js';
|
||||
|
||||
// Load balancer helpers
|
||||
export {
|
||||
createLoadBalancerRoute,
|
||||
createSmartLoadBalancer
|
||||
} from './load-balancer-helpers.js';
|
||||
|
||||
// NFTables helpers
|
||||
export {
|
||||
createNfTablesRoute,
|
||||
createNfTablesTerminateRoute,
|
||||
createCompleteNfTablesHttpsServer
|
||||
} from './nftables-helpers.js';
|
||||
|
||||
// Dynamic routing helpers
|
||||
export {
|
||||
createPortOffset,
|
||||
createPortMappingRoute,
|
||||
createOffsetPortMappingRoute,
|
||||
createDynamicRoute
|
||||
} from './dynamic-helpers.js';
|
||||
|
||||
// API helpers
|
||||
export {
|
||||
createApiRoute,
|
||||
createApiGatewayRoute
|
||||
} from './api-helpers.js';
|
||||
|
||||
// Security helpers
|
||||
export {
|
||||
addRateLimiting,
|
||||
addBasicAuth,
|
||||
addJwtAuth
|
||||
} from './security-helpers.js';
|
||||
|
||||
// Socket handlers
|
||||
export {
|
||||
SocketHandlers,
|
||||
createSocketHandlerRoute
|
||||
} from './socket-handlers.js';
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Load Balancer Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating load balancer route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create a load balancer route (round-robin between multiple backend hosts)
|
||||
* @param domains Domain(s) to match
|
||||
* @param backendsOrHosts Array of backend servers OR array of host strings (legacy)
|
||||
* @param portOrOptions Port number (legacy) OR options object
|
||||
* @param options Additional route options (legacy)
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createLoadBalancerRoute(
|
||||
domains: string | string[],
|
||||
backendsOrHosts: Array<{ host: string; port: number }> | string[],
|
||||
portOrOptions?: number | {
|
||||
tls?: {
|
||||
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
};
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
algorithm?: 'round-robin' | 'least-connections' | 'ip-hash';
|
||||
healthCheck?: {
|
||||
path: string;
|
||||
interval: number;
|
||||
timeout: number;
|
||||
unhealthyThreshold: number;
|
||||
healthyThreshold: number;
|
||||
};
|
||||
[key: string]: any;
|
||||
},
|
||||
options?: {
|
||||
tls?: {
|
||||
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
): IRouteConfig {
|
||||
// Handle legacy signature: (domains, hosts[], port, options)
|
||||
let backends: Array<{ host: string; port: number }>;
|
||||
let finalOptions: any;
|
||||
|
||||
if (Array.isArray(backendsOrHosts) && backendsOrHosts.length > 0 && typeof backendsOrHosts[0] === 'string') {
|
||||
// Legacy signature
|
||||
const hosts = backendsOrHosts as string[];
|
||||
const port = portOrOptions as number;
|
||||
backends = hosts.map(host => ({ host, port }));
|
||||
finalOptions = options || {};
|
||||
} else {
|
||||
// New signature
|
||||
backends = backendsOrHosts as Array<{ host: string; port: number }>;
|
||||
finalOptions = (portOrOptions as any) || {};
|
||||
}
|
||||
|
||||
// Extract hosts and ensure all backends use the same port
|
||||
const port = backends[0].port;
|
||||
const hosts = backends.map(backend => backend.host);
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: finalOptions.match?.ports || (finalOptions.tls || finalOptions.useTls ? 443 : 80),
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route target
|
||||
const target: IRouteTarget = {
|
||||
host: hosts,
|
||||
port
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target]
|
||||
};
|
||||
|
||||
// Add TLS configuration if provided
|
||||
if (finalOptions.tls || finalOptions.useTls) {
|
||||
action.tls = {
|
||||
mode: finalOptions.tls?.mode || 'terminate',
|
||||
certificate: finalOptions.tls?.certificate || finalOptions.certificate || 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
// Add load balancing options
|
||||
if (finalOptions.algorithm || finalOptions.healthCheck) {
|
||||
action.loadBalancing = {
|
||||
algorithm: finalOptions.algorithm || 'round-robin',
|
||||
healthCheck: finalOptions.healthCheck
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: finalOptions.name || `Load Balancer for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
...finalOptions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a smart load balancer with dynamic domain-based backend selection
|
||||
* @param options Smart load balancer options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createSmartLoadBalancer(options: {
|
||||
ports: TPortRange;
|
||||
domainTargets: Record<string, string | string[]>;
|
||||
portMapper: (context: IRouteContext) => number;
|
||||
name?: string;
|
||||
defaultTarget?: string | string[];
|
||||
priority?: number;
|
||||
[key: string]: any;
|
||||
}): IRouteConfig {
|
||||
// Extract all domain keys to create the match criteria
|
||||
const domains = Object.keys(options.domainTargets);
|
||||
|
||||
// Create the smart host selector function
|
||||
const hostSelector = (context: IRouteContext) => {
|
||||
const domain = context.domain || '';
|
||||
return options.domainTargets[domain] || options.defaultTarget || 'localhost';
|
||||
};
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: options.ports,
|
||||
domains
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: hostSelector,
|
||||
port: options.portMapper
|
||||
}]
|
||||
};
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: options.name || `Smart Load Balancer for ${domains.join(', ')}`,
|
||||
priority: options.priority,
|
||||
...options
|
||||
};
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* NFTables Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating NFTables-based route configurations
|
||||
* for high-performance packet forwarding at the kernel level.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../../models/route-types.js';
|
||||
import { createHttpToHttpsRedirect } from './https-helpers.js';
|
||||
|
||||
/**
|
||||
* Create an NFTables-based route for high-performance packet forwarding
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createNfTablesRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
useTls?: boolean;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Determine if this is a name or domain
|
||||
let name: string;
|
||||
let domains: string | string[] | undefined;
|
||||
|
||||
if (Array.isArray(nameOrDomains) || (typeof nameOrDomains === 'string' && nameOrDomains.includes('.'))) {
|
||||
domains = nameOrDomains;
|
||||
name = Array.isArray(nameOrDomains) ? nameOrDomains[0] : nameOrDomains;
|
||||
} else {
|
||||
name = nameOrDomains;
|
||||
domains = undefined; // No domains
|
||||
}
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
domains,
|
||||
ports: options.ports || 80
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [{
|
||||
host: target.host,
|
||||
port: target.port
|
||||
}],
|
||||
forwardingEngine: 'nftables',
|
||||
nftables: {
|
||||
protocol: options.protocol || 'tcp',
|
||||
preserveSourceIP: options.preserveSourceIP,
|
||||
maxRate: options.maxRate,
|
||||
priority: options.priority,
|
||||
tableName: options.tableName,
|
||||
useIPSets: options.useIPSets,
|
||||
useAdvancedNAT: options.useAdvancedNAT
|
||||
}
|
||||
};
|
||||
|
||||
// Add TLS options if needed
|
||||
if (options.useTls) {
|
||||
action.tls = {
|
||||
mode: 'passthrough'
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
const routeConfig: IRouteConfig = {
|
||||
name,
|
||||
match,
|
||||
action
|
||||
};
|
||||
|
||||
// Add security if allowed or blocked IPs are specified
|
||||
if (options.ipAllowList?.length || options.ipBlockList?.length) {
|
||||
routeConfig.security = {
|
||||
ipAllowList: options.ipAllowList,
|
||||
ipBlockList: options.ipBlockList
|
||||
};
|
||||
}
|
||||
|
||||
return routeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NFTables-based TLS termination route
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createNfTablesTerminateRoute(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
ports?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
// Create basic NFTables route
|
||||
const route = createNfTablesRoute(
|
||||
nameOrDomains,
|
||||
target,
|
||||
{
|
||||
...options,
|
||||
ports: options.ports || 443,
|
||||
useTls: false
|
||||
}
|
||||
);
|
||||
|
||||
// Set TLS termination
|
||||
route.action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: options.certificate || 'auto'
|
||||
};
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete NFTables-based HTTPS setup with HTTP redirect
|
||||
* @param nameOrDomains Name or domain(s) to match
|
||||
* @param target Target host and port
|
||||
* @param options Additional route options
|
||||
* @returns Array of two route configurations (HTTPS and HTTP redirect)
|
||||
*/
|
||||
export function createCompleteNfTablesHttpsServer(
|
||||
nameOrDomains: string | string[],
|
||||
target: { host: string; port: number | 'preserve' },
|
||||
options: {
|
||||
httpPort?: TPortRange;
|
||||
httpsPort?: TPortRange;
|
||||
protocol?: 'tcp' | 'udp' | 'all';
|
||||
preserveSourceIP?: boolean;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxRate?: string;
|
||||
priority?: number;
|
||||
tableName?: string;
|
||||
useIPSets?: boolean;
|
||||
useAdvancedNAT?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
} = {}
|
||||
): IRouteConfig[] {
|
||||
// Create the HTTPS route using NFTables
|
||||
const httpsRoute = createNfTablesTerminateRoute(
|
||||
nameOrDomains,
|
||||
target,
|
||||
{
|
||||
...options,
|
||||
ports: options.httpsPort || 443
|
||||
}
|
||||
);
|
||||
|
||||
// Determine the domain(s) for HTTP redirect
|
||||
const domains = typeof nameOrDomains === 'string' && !nameOrDomains.includes('.')
|
||||
? undefined
|
||||
: nameOrDomains;
|
||||
|
||||
// Extract the HTTPS port for the redirect destination
|
||||
const httpsPort = typeof options.httpsPort === 'number'
|
||||
? options.httpsPort
|
||||
: Array.isArray(options.httpsPort) && typeof options.httpsPort[0] === 'number'
|
||||
? options.httpsPort[0]
|
||||
: 443;
|
||||
|
||||
// Create the HTTP redirect route (this uses standard forwarding, not NFTables)
|
||||
const httpRedirectRoute = createHttpToHttpsRedirect(
|
||||
domains as any, // Type cast needed since domains can be undefined now
|
||||
httpsPort,
|
||||
{
|
||||
match: {
|
||||
ports: options.httpPort || 80,
|
||||
domains: domains as any // Type cast needed since domains can be undefined now
|
||||
},
|
||||
name: `HTTP to HTTPS Redirect for ${Array.isArray(domains) ? domains.join(', ') : domains || 'all domains'}`
|
||||
}
|
||||
);
|
||||
|
||||
return [httpsRoute, httpRedirectRoute];
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Security Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for adding security features to routes.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig } from '../../models/route-types.js';
|
||||
import { mergeRouteConfigs } from '../route-utils.js';
|
||||
|
||||
/**
|
||||
* Create a rate limiting route pattern
|
||||
* @param baseRoute Base route to add rate limiting to
|
||||
* @param rateLimit Rate limiting configuration
|
||||
* @returns Route with rate limiting
|
||||
*/
|
||||
export function addRateLimiting(
|
||||
baseRoute: IRouteConfig,
|
||||
rateLimit: {
|
||||
maxRequests: number;
|
||||
window: number; // Time window in seconds
|
||||
keyBy?: 'ip' | 'path' | 'header';
|
||||
headerName?: string; // Required if keyBy is 'header'
|
||||
errorMessage?: string;
|
||||
}
|
||||
): IRouteConfig {
|
||||
return mergeRouteConfigs(baseRoute, {
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: rateLimit.maxRequests,
|
||||
window: rateLimit.window,
|
||||
keyBy: rateLimit.keyBy || 'ip',
|
||||
headerName: rateLimit.headerName,
|
||||
errorMessage: rateLimit.errorMessage || 'Rate limit exceeded. Please try again later.'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic authentication route pattern
|
||||
* @param baseRoute Base route to add authentication to
|
||||
* @param auth Authentication configuration
|
||||
* @returns Route with basic authentication
|
||||
*/
|
||||
export function addBasicAuth(
|
||||
baseRoute: IRouteConfig,
|
||||
auth: {
|
||||
users: Array<{ username: string; password: string }>;
|
||||
realm?: string;
|
||||
excludePaths?: string[];
|
||||
}
|
||||
): IRouteConfig {
|
||||
return mergeRouteConfigs(baseRoute, {
|
||||
security: {
|
||||
basicAuth: {
|
||||
enabled: true,
|
||||
users: auth.users,
|
||||
realm: auth.realm || 'Restricted Area',
|
||||
excludePaths: auth.excludePaths || []
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JWT authentication route pattern
|
||||
* @param baseRoute Base route to add JWT authentication to
|
||||
* @param jwt JWT authentication configuration
|
||||
* @returns Route with JWT authentication
|
||||
*/
|
||||
export function addJwtAuth(
|
||||
baseRoute: IRouteConfig,
|
||||
jwt: {
|
||||
secret: string;
|
||||
algorithm?: string;
|
||||
issuer?: string;
|
||||
audience?: string;
|
||||
expiresIn?: number; // Time in seconds
|
||||
excludePaths?: string[];
|
||||
}
|
||||
): IRouteConfig {
|
||||
return mergeRouteConfigs(baseRoute, {
|
||||
security: {
|
||||
jwtAuth: {
|
||||
enabled: true,
|
||||
secret: jwt.secret,
|
||||
algorithm: jwt.algorithm || 'HS256',
|
||||
issuer: jwt.issuer,
|
||||
audience: jwt.audience,
|
||||
expiresIn: jwt.expiresIn,
|
||||
excludePaths: jwt.excludePaths || []
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* WebSocket Route Helper Functions
|
||||
*
|
||||
* This module provides utility functions for creating WebSocket route configurations.
|
||||
*/
|
||||
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction } from '../../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Create a WebSocket route configuration
|
||||
* @param domains Domain(s) to match
|
||||
* @param targetOrPath Target server OR WebSocket path (legacy)
|
||||
* @param targetOrOptions Target server (legacy) OR options
|
||||
* @param options Additional route options (legacy)
|
||||
* @returns Route configuration object
|
||||
*/
|
||||
export function createWebSocketRoute(
|
||||
domains: string | string[],
|
||||
targetOrPath: { host: string | string[]; port: number } | string,
|
||||
targetOrOptions?: { host: string | string[]; port: number } | {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
path?: string;
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
pingInterval?: number;
|
||||
pingTimeout?: number;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
},
|
||||
options?: {
|
||||
useTls?: boolean;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
httpPort?: number | number[];
|
||||
httpsPort?: number | number[];
|
||||
pingInterval?: number;
|
||||
pingTimeout?: number;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
): IRouteConfig {
|
||||
// Handle different signatures
|
||||
let target: { host: string | string[]; port: number };
|
||||
let wsPath: string;
|
||||
let finalOptions: any;
|
||||
|
||||
if (typeof targetOrPath === 'string') {
|
||||
// Legacy signature: (domains, path, target, options)
|
||||
wsPath = targetOrPath;
|
||||
target = targetOrOptions as { host: string | string[]; port: number };
|
||||
finalOptions = options || {};
|
||||
} else {
|
||||
// New signature: (domains, target, options)
|
||||
target = targetOrPath;
|
||||
finalOptions = (targetOrOptions as any) || {};
|
||||
wsPath = finalOptions.path || '/ws';
|
||||
}
|
||||
|
||||
// Normalize WebSocket path
|
||||
const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`;
|
||||
|
||||
// Create route match
|
||||
const match: IRouteMatch = {
|
||||
ports: finalOptions.useTls
|
||||
? (finalOptions.httpsPort || 443)
|
||||
: (finalOptions.httpPort || 80),
|
||||
domains,
|
||||
path: normalizedPath
|
||||
};
|
||||
|
||||
// Create route action
|
||||
const action: IRouteAction = {
|
||||
type: 'forward',
|
||||
targets: [target],
|
||||
websocket: {
|
||||
enabled: true,
|
||||
pingInterval: finalOptions.pingInterval || 30000, // 30 seconds
|
||||
pingTimeout: finalOptions.pingTimeout || 5000 // 5 seconds
|
||||
}
|
||||
};
|
||||
|
||||
// Add TLS configuration if using HTTPS
|
||||
if (finalOptions.useTls) {
|
||||
action.tls = {
|
||||
mode: 'terminate',
|
||||
certificate: finalOptions.certificate || 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
// Create the route config
|
||||
return {
|
||||
match,
|
||||
action,
|
||||
name: finalOptions.name || `WebSocket Route ${normalizedPath} for ${Array.isArray(domains) ? domains.join(', ') : domains}`,
|
||||
priority: finalOptions.priority || 100, // Higher priority for WebSocket routes
|
||||
...finalOptions
|
||||
};
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
* like echoing, proxying, HTTP responses, and redirects.
|
||||
*/
|
||||
|
||||
import * as plugins from '../../../../plugins.js';
|
||||
import type { IRouteConfig, TPortRange, IRouteContext } from '../../models/route-types.js';
|
||||
import { createSocketTracker } from '../../../../core/utils/socket-tracker.js';
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { IRouteContext } from '../models/route-types.js';
|
||||
import { createSocketTracker } from '../../../core/utils/socket-tracker.js';
|
||||
|
||||
/**
|
||||
* Minimal HTTP request parser for socket handlers.
|
||||
@@ -308,31 +308,3 @@ export const SocketHandlers = {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a socket handler route configuration
|
||||
*/
|
||||
export function createSocketHandlerRoute(
|
||||
domains: string | string[],
|
||||
ports: TPortRange,
|
||||
handler: (socket: plugins.net.Socket) => void | Promise<void>,
|
||||
options: {
|
||||
name?: string;
|
||||
priority?: number;
|
||||
path?: string;
|
||||
} = {}
|
||||
): IRouteConfig {
|
||||
return {
|
||||
name: options.name || 'socket-handler-route',
|
||||
priority: options.priority !== undefined ? options.priority : 50,
|
||||
match: {
|
||||
domains,
|
||||
ports,
|
||||
...(options.path && { path: options.path })
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: handler
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"ignoreDeprecations": "6.0"
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user