Compare commits

...

17 Commits

Author SHA1 Message Date
Juergen Kunz
e9c753d2a9 BREAKING_CHANGE(routing): refactor route configuration to support multiple targets
Some checks failed
Default (tags) / security (push) Successful in 55s
Default (tags) / test (push) Failing after 31m9s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 08:45:13 +00:00
Juergen Kunz
6aa5f415c1 update 2025-07-17 20:51:50 +00:00
Juergen Kunz
b26abbfd87 update 2025-07-17 15:34:58 +00:00
Juergen Kunz
82df9a6f52 update 2025-07-17 15:13:09 +00:00
Juergen Kunz
a625675922 19.6.17
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 30m42s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-13 00:41:50 +00:00
Juergen Kunz
eac6075a12 fix(cert): fix tsclass ICert usage 2025-07-13 00:41:44 +00:00
Juergen Kunz
2d2e9e9475 feat(certificates): add custom provisioning option 2025-07-13 00:27:49 +00:00
Juergen Kunz
257a5dc319 update 2025-07-13 00:05:32 +00:00
Juergen Kunz
5d206b9800 add plan for better cert provisioning 2025-07-12 21:58:46 +00:00
Juergen Kunz
f82d44164c 19.6.16
Some checks failed
Default (tags) / security (push) Successful in 1m20s
Default (tags) / test (push) Failing after 29m31s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-03 03:17:35 +00:00
Juergen Kunz
2a4ed38f6b update logs 2025-07-03 02:54:56 +00:00
Juergen Kunz
bb2c82b44a 19.6.15
Some checks failed
Default (tags) / security (push) Successful in 1m22s
Default (tags) / test (push) Failing after 29m38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-03 02:45:30 +00:00
Juergen Kunz
dddcf8dec4 improve logging 2025-07-03 02:45:08 +00:00
Juergen Kunz
8d7213e91b 19.6.14
Some checks failed
Default (tags) / security (push) Successful in 1m24s
Default (tags) / test (push) Failing after 29m37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-03 02:33:04 +00:00
Juergen Kunz
5d011ba84c better logging 2025-07-03 02:32:17 +00:00
Juergen Kunz
67aff4bb30 19.6.13
Some checks failed
Default (tags) / security (push) Successful in 1m25s
Default (tags) / test (push) Failing after 29m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 15:42:39 +00:00
Juergen Kunz
3857d2670f fix(metrics): fix metrics 2025-06-23 15:42:04 +00:00
83 changed files with 6006 additions and 791 deletions

View File

@@ -1,5 +1,5 @@
{ {
"expiryDate": "2025-09-21T08:37:03.077Z", "expiryDate": "2025-10-18T13:15:48.916Z",
"issueDate": "2025-06-23T08:37:03.077Z", "issueDate": "2025-07-20T13:15:48.916Z",
"savedAt": "2025-06-23T08:37:03.078Z" "savedAt": "2025-07-20T13:15:48.916Z"
} }

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2025-07-20 - 20.0.0 - BREAKING_CHANGE(routing)
Refactor route configuration to support multiple targets
- Changed route action configuration from single `target` to `targets` array
- Enables load balancing and failover capabilities with multiple upstream targets
- Updated all test files to use new `targets` array syntax
- Automatic certificate metadata refresh
## 2025-06-01 - 19.5.19 - fix(smartproxy) ## 2025-06-01 - 19.5.19 - fix(smartproxy)
Fix connection handling and improve route matching edge cases Fix connection handling and improve route matching edge cases

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "19.6.12", "version": "20.0.0",
"private": false, "private": false,
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.", "description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@@ -143,3 +143,206 @@ The system supports both receiving and sending PROXY protocol:
- **Receiving**: Automatically detected from trusted proxy IPs (configured in `proxyIPs`) - **Receiving**: Automatically detected from trusted proxy IPs (configured in `proxyIPs`)
- **Sending**: Enabled per-route or globally via `sendProxyProtocol` setting - **Sending**: Enabled per-route or globally via `sendProxyProtocol` setting
- Real client IP is preserved and used for all connection tracking and security checks - Real client IP is preserved and used for all connection tracking and security checks
## Metrics and Throughput Calculation
The metrics system tracks throughput using per-second sampling:
1. **Byte Recording**: Bytes are recorded as data flows through connections
2. **Sampling**: Every second, accumulated bytes are stored as a sample
3. **Rate Calculation**: Throughput is calculated by summing bytes over a time window
4. **Per-Route/IP Tracking**: Separate ThroughputTracker instances for each route and IP
Key implementation details:
- Bytes are recorded in the bidirectional forwarding callbacks
- The instant() method returns throughput over the last 1 second
- The recent() method returns throughput over the last 10 seconds
- Custom windows can be specified for different averaging periods
### Throughput Spikes Issue
There's a fundamental difference between application-layer and network-layer throughput:
**Application Layer (what we measure)**:
- Bytes are recorded when delivered to/from the application
- Large chunks can arrive "instantly" due to kernel/Node.js buffering
- Shows spikes when buffers are flushed (e.g., 20MB in 1 second = 160 Mbit/s)
**Network Layer (what Unifi shows)**:
- Actual packet flow through the network interface
- Limited by physical network speed (e.g., 20 Mbit/s)
- Data transfers over time, not in bursts
The spikes occur because:
1. Data flows over network at 20 Mbit/s (takes 8 seconds for 20MB)
2. Kernel/Node.js buffers this incoming data
3. When buffer is flushed, application receives large chunk at once
4. We record entire chunk in current second, creating artificial spike
**Potential Solutions**:
1. Use longer window for "instant" measurements (e.g., 5 seconds instead of 1)
2. Track socket write backpressure to estimate actual network flow
3. Implement bandwidth estimation based on connection duration
4. Accept that application-layer != network-layer throughput
## Connection Limiting
### Per-IP Connection Limits
- SmartProxy tracks connections per IP address in the SecurityManager
- Default limit is 100 connections per IP (configurable via `maxConnectionsPerIP`)
- Connection rate limiting is also enforced (default 300 connections/minute per IP)
- HttpProxy has been enhanced to also enforce per-IP limits when forwarding from SmartProxy
### Route-Level Connection Limits
- Routes can define `security.maxConnections` to limit connections per route
- ConnectionManager tracks connections by route ID using a separate Map
- Limits are enforced in RouteConnectionHandler before forwarding
- Connection is tracked when route is matched: `trackConnectionByRoute(routeId, connectionId)`
### HttpProxy Integration
- When SmartProxy forwards to HttpProxy for TLS termination, it sends a `CLIENT_IP:<ip>\r\n` header
- HttpProxy parses this header to track the real client IP, not the localhost IP
- This ensures per-IP limits are enforced even for forwarded connections
- The header is parsed in the connection handler before any data processing
### Memory Optimization
- Periodic cleanup runs every 60 seconds to remove:
- IPs with no active connections
- Expired rate limit timestamps (older than 1 minute)
- Prevents memory accumulation from many unique IPs over time
- Cleanup is automatic and runs in background with `unref()` to not keep process alive
### Connection Cleanup Queue
- Cleanup queue processes connections in batches to prevent overwhelming the system
- Race condition prevention using `isProcessingCleanup` flag
- Try-finally block ensures flag is always reset even if errors occur
- New connections added during processing are queued for next batch
### Important Implementation Notes
- Always use `NodeJS.Timeout` type instead of `NodeJS.Timer` for interval/timeout references
- IPv4/IPv6 normalization is handled (e.g., `::ffff:127.0.0.1` and `127.0.0.1` are treated as the same IP)
- Connection limits are checked before route matching to prevent DoS attacks
- SharedSecurityManager supports checking route-level limits via optional parameter
## Log Deduplication
To reduce log spam during high-traffic scenarios or attacks, SmartProxy implements log deduplication for repetitive events:
### How It Works
- Similar log events are batched and aggregated over a 5-second window
- Instead of logging each event individually, a summary is emitted
- Events are grouped by type and deduplicated by key (e.g., IP address, reason)
### Deduplicated Event Types
1. **Connection Rejections** (`connection-rejected`):
- Groups by rejection reason (global-limit, route-limit, etc.)
- Example: "Rejected 150 connections (reasons: global-limit: 100, route-limit: 50)"
2. **IP Rejections** (`ip-rejected`):
- Groups by IP address
- Shows top offenders with rejection counts and reasons
- Example: "Rejected 500 connections from 10 IPs (top offenders: 192.168.1.100 (200x, rate-limit), ...)"
3. **Connection Cleanups** (`connection-cleanup`):
- Groups by cleanup reason (normal, timeout, error, zombie, etc.)
- Example: "Cleaned up 250 connections (reasons: normal: 200, timeout: 30, error: 20)"
4. **IP Tracking Cleanup** (`ip-cleanup`):
- Summarizes periodic IP cleanup operations
- Example: "IP tracking cleanup: removed 50 entries across 5 cleanup cycles"
### Configuration
- Default flush interval: 5 seconds
- Maximum batch size: 100 events (triggers immediate flush)
- Global periodic flush: Every 10 seconds (ensures logs are emitted regularly)
- Process exit handling: Logs are flushed on SIGINT/SIGTERM
### Benefits
- Reduces log volume during attacks or high traffic
- Provides better overview of patterns (e.g., which IPs are attacking)
- Improves log readability and analysis
- Prevents log storage overflow
- Maintains detailed information in aggregated form
### Log Output Examples
Instead of hundreds of individual logs:
```
Connection rejected
Connection rejected
Connection rejected
... (repeated 500 times)
```
You'll see:
```
[SUMMARY] Rejected 500 connections from 10 IPs in 5s (rate-limit: 350, per-ip-limit: 150) (top offenders: 192.168.1.100 (200x, rate-limit), 10.0.0.1 (150x, per-ip-limit))
```
Instead of:
```
Connection terminated: ::ffff:127.0.0.1 (client_closed). Active: 266
Connection terminated: ::ffff:127.0.0.1 (client_closed). Active: 265
... (repeated 266 times)
```
You'll see:
```
[SUMMARY] 266 HttpProxy connections terminated in 5s (reasons: client_closed: 266, activeConnections: 0)
```
### Rapid Event Handling
- During attacks or high-volume scenarios, logs are flushed more frequently
- If 50+ events occur within 1 second, immediate flush is triggered
- Prevents memory buildup during flooding attacks
- Maintains real-time visibility during incidents
## Custom Certificate Provision Function
The `certProvisionFunction` feature has been implemented to allow users to provide their own certificate generation logic.
### Implementation Details
1. **Type Definition**: The function must return `Promise<TSmartProxyCertProvisionObject>` where:
- `TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'`
- Return `'http01'` to fallback to Let's Encrypt
- Return a certificate object for custom certificates
2. **Certificate Manager Changes**:
- Added `certProvisionFunction` property to CertificateManager
- Modified `provisionAcmeCertificate()` to check custom function first
- Custom certificates are stored with source type 'custom'
- Expiry date extraction currently defaults to 90 days
3. **Configuration Options**:
- `certProvisionFunction`: The custom provision function
- `certProvisionFallbackToAcme`: Whether to fallback to ACME on error (default: true)
4. **Usage Example**:
```typescript
new SmartProxy({
certProvisionFunction: async (domain: string) => {
if (domain === 'internal.example.com') {
return {
cert: customCert,
key: customKey,
ca: customCA
} as unknown as TSmartProxyCertProvisionObject;
}
return 'http01'; // Use Let's Encrypt
},
certProvisionFallbackToAcme: true
})
```
5. **Testing Notes**:
- Type assertions through `unknown` are needed in tests due to strict interface typing
- Mock certificate objects work for testing but need proper type casting
- The actual certificate parsing for expiry dates would need a proper X.509 parser
### Future Improvements
1. Implement proper certificate expiry date extraction using X.509 parsing
2. Add support for returning expiry date with custom certificates
3. Consider adding validation for custom certificate format
4. Add events/hooks for certificate provisioning lifecycle

107
readme.md
View File

@@ -2336,14 +2336,117 @@ sequenceDiagram
• Efficient SNI extraction • Efficient SNI extraction
• Minimal overhead routing • Minimal overhead routing
## Certificate Hooks & Events ## Certificate Management
### Custom Certificate Provision Function
SmartProxy supports a custom certificate provision function that allows you to provide your own certificate generation logic while maintaining compatibility with Let's Encrypt:
```typescript
const proxy = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
// Option 1: Return a custom certificate
if (domain === 'internal.example.com') {
return {
cert: customCertPEM,
key: customKeyPEM,
ca: customCAPEM // Optional CA chain
};
}
// Option 2: Fallback to Let's Encrypt
return 'http01';
},
// Control fallback behavior when custom provision fails
certProvisionFallbackToAcme: true, // Default: true
routes: [...]
});
```
**Key Features:**
- Called for any route with `certificate: 'auto'`
- Return custom certificate object or `'http01'` to use Let's Encrypt
- Participates in automatic renewal cycle (checked every 12 hours)
- Custom certificates stored with source type 'custom' for tracking
**Configuration Options:**
- `certProvisionFunction`: Async function that receives domain and returns certificate or 'http01'
- `certProvisionFallbackToAcme`: Whether to fallback to Let's Encrypt if custom provision fails (default: true)
**Advanced Example with Certificate Manager:**
```typescript
const certManager = new MyCertificateManager();
const proxy = new SmartProxy({
certProvisionFunction: async (domain: string) => {
try {
// Check if we have a custom certificate for this domain
if (await certManager.hasCustomCert(domain)) {
const cert = await certManager.getCertificate(domain);
return {
cert: cert.certificate,
key: cert.privateKey,
ca: cert.chain
};
}
// Use Let's Encrypt for public domains
if (domain.endsWith('.example.com')) {
return 'http01';
}
// Generate self-signed for internal domains
if (domain.endsWith('.internal')) {
const selfSigned = await certManager.generateSelfSigned(domain);
return {
cert: selfSigned.cert,
key: selfSigned.key,
ca: ''
};
}
// Default to Let's Encrypt
return 'http01';
} catch (error) {
console.error(`Certificate provision failed for ${domain}:`, error);
// Will fallback to Let's Encrypt if certProvisionFallbackToAcme is true
throw error;
}
},
certProvisionFallbackToAcme: true,
routes: [
// Routes that use automatic certificates
{
match: { ports: 443, domains: ['app.example.com', '*.internal'] },
action: {
type: 'forward',
target: { host: 'localhost', port: 8080 },
tls: { mode: 'terminate', certificate: 'auto' }
}
}
]
});
```
### Certificate Events
Listen for certificate events via EventEmitter: Listen for certificate events via EventEmitter:
- **SmartProxy**: - **SmartProxy**:
- `certificate` (domain, publicKey, privateKey, expiryDate, source, isRenewal) - `certificate` (domain, publicKey, privateKey, expiryDate, source, isRenewal)
- Events from CertManager are propagated - Events from CertManager are propagated
Provide a `certProvisionFunction(domain)` in SmartProxy settings to supply static certs or return `'http01'`. ```typescript
proxy.on('certificate', (domain, cert, key, expiryDate, source, isRenewal) => {
console.log(`Certificate ${isRenewal ? 'renewed' : 'provisioned'} for ${domain}`);
console.log(`Source: ${source}`); // 'acme', 'static', or 'custom'
console.log(`Expires: ${expiryDate}`);
});
```
## SmartProxy: Common Use Cases ## SmartProxy: Common Use Cases

View File

@@ -1,364 +1,154 @@
# SmartProxy Metrics Improvement Plan # SmartProxy Enhanced Routing Plan
## Overview ## Goal
Implement enhanced routing structure with multiple targets per route, sub-matching capabilities, and target-specific overrides to enable more elegant and DRY configurations.
The current `getThroughputRate()` implementation calculates cumulative throughput over a 60-second window rather than providing an actual rate, making metrics misleading for monitoring systems. This plan outlines a comprehensive redesign of the metrics system to provide accurate, time-series based metrics suitable for production monitoring. ## Key Changes
## 1. Core Issues with Current Implementation ### 1. Update Route Target Interface
- Add `match` property to `IRouteTarget` for sub-matching within routes
- Add target-specific override properties (tls, websocket, loadBalancing, etc.)
- Add priority field for controlling match order
- **Cumulative vs Rate**: Current method accumulates all bytes from connections in the last minute rather than calculating actual throughput rate ### 2. Update Route Action Interface
- **No Time-Series Data**: Cannot track throughput changes over time - Remove singular `target` property
- **Inaccurate Estimates**: Attempting to estimate rates for older connections is fundamentally flawed - Use only `targets` array (single target = array with one element)
- **No Sliding Windows**: Cannot provide different time window views (1s, 10s, 60s, etc.) - Maintain backwards compatibility during migration
- **Limited Granularity**: Only provides a single 60-second view
## 2. Proposed Architecture ### 3. Implementation Steps
### A. Time-Series Throughput Tracking #### Phase 1: Type Updates
- [x] Update `IRouteTarget` interface in `route-types.ts`
- Add `match?: ITargetMatch` property
- Add override properties (tls, websocket, etc.)
- Add `priority?: number` field
- [x] Create `ITargetMatch` interface for sub-matching criteria
- [x] Update `IRouteAction` to use only `targets: IRouteTarget[]`
#### Phase 2: Route Resolution Logic
- [x] Update route matching logic to handle multiple targets
- [x] Implement target sub-matching algorithm:
1. Sort targets by priority (highest first)
2. For each target with a match property, check if request matches
3. Use first matching target, or fallback to target without match
- [x] Ensure target-specific settings override route-level settings
#### Phase 3: Code Migration
- [x] Find all occurrences of `action.target` and update to use `action.targets`
- [x] Update route helpers and utilities
- [x] Update certificate manager to handle multiple targets
- [x] Update connection handlers
#### Phase 4: Testing
- [x] Update existing tests to use new format
- [ ] Add tests for multi-target scenarios
- [ ] Add tests for sub-matching logic
- [ ] Add tests for setting overrides
#### Phase 5: Documentation
- [ ] Update type documentation
- [ ] Add examples of new routing patterns
- [ ] Document migration path for existing configs
## Example Configurations
### Before (Current)
```typescript ```typescript
interface IThroughputSample { // Need separate routes for different ports/paths
timestamp: number; [
bytesIn: number; {
bytesOut: number; match: { domains: ['api.example.com'], ports: [80] },
} action: {
type: 'forward',
class ThroughputTracker { target: { host: 'backend', port: 8080 },
private samples: IThroughputSample[] = []; tls: { mode: 'terminate' }
private readonly MAX_SAMPLES = 3600; // 1 hour at 1 sample/second
private lastSampleTime: number = 0;
private accumulatedBytesIn: number = 0;
private accumulatedBytesOut: number = 0;
// Called on every data transfer
public recordBytes(bytesIn: number, bytesOut: number): void {
this.accumulatedBytesIn += bytesIn;
this.accumulatedBytesOut += bytesOut;
}
// Called periodically (every second)
public takeSample(): void {
const now = Date.now();
// Record accumulated bytes since last sample
this.samples.push({
timestamp: now,
bytesIn: this.accumulatedBytesIn,
bytesOut: this.accumulatedBytesOut
});
// Reset accumulators
this.accumulatedBytesIn = 0;
this.accumulatedBytesOut = 0;
// Trim old samples
const cutoff = now - 3600000; // 1 hour
this.samples = this.samples.filter(s => s.timestamp > cutoff);
}
// Get rate over specified window
public getRate(windowSeconds: number): { bytesInPerSec: number; bytesOutPerSec: number } {
const now = Date.now();
const windowStart = now - (windowSeconds * 1000);
const relevantSamples = this.samples.filter(s => s.timestamp > windowStart);
if (relevantSamples.length === 0) {
return { bytesInPerSec: 0, bytesOutPerSec: 0 };
} }
},
{
match: { domains: ['api.example.com'], ports: [443] },
action: {
type: 'forward',
target: { host: 'backend', port: 8081 },
tls: { mode: 'passthrough' }
}
}
]
```
const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0); ### After (Enhanced)
const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0); ```typescript
// Single route with multiple targets
const actualWindow = (now - relevantSamples[0].timestamp) / 1000; {
match: { domains: ['api.example.com'], ports: [80, 443] },
return { action: {
bytesInPerSec: Math.round(totalBytesIn / actualWindow), type: 'forward',
bytesOutPerSec: Math.round(totalBytesOut / actualWindow) targets: [
}; {
match: { ports: [80] },
host: 'backend',
port: 8080,
tls: { mode: 'terminate' }
},
{
match: { ports: [443] },
host: 'backend',
port: 8081,
tls: { mode: 'passthrough' }
}
]
} }
} }
``` ```
### B. Connection-Level Byte Tracking ### Advanced Example
```typescript ```typescript
// In ConnectionRecord, add: {
interface IConnectionRecord { match: { domains: ['app.example.com'], ports: [443] },
// ... existing fields ... action: {
type: 'forward',
// Byte counters with timestamps tls: { mode: 'terminate', certificate: 'auto' }, // Route-level default
bytesReceivedHistory: Array<{ timestamp: number; bytes: number }>; websocket: { enabled: true }, // Route-level default
bytesSentHistory: Array<{ timestamp: number; bytes: number }>; targets: [
{
// For efficiency, could use circular buffer match: { path: '/api/v2/*' },
lastBytesReceivedUpdate: number; host: 'api-v2',
lastBytesSentUpdate: number; port: 8082,
} priority: 10
``` },
{
### C. Enhanced Metrics Interface match: { path: '/api/*', headers: { 'X-Version': 'v1' } },
host: 'api-v1',
```typescript port: 8081,
interface IMetrics { priority: 5
// Connection metrics },
connections: { {
active(): number; match: { path: '/ws/*' },
total(): number; host: 'websocket-server',
byRoute(): Map<string, number>; port: 8090,
byIP(): Map<string, number>; websocket: {
topIPs(limit?: number): Array<{ ip: string; count: number }>; enabled: true,
}; rewritePath: '/' // Strip /ws prefix
}
// Throughput metrics (bytes per second) },
throughput: { {
instant(): { in: number; out: number }; // Last 1 second // Default target (no match property)
recent(): { in: number; out: number }; // Last 10 seconds host: 'web-backend',
average(): { in: number; out: number }; // Last 60 seconds port: 8080
custom(seconds: number): { in: number; out: number }; }
history(seconds: number): Array<{ timestamp: number; in: number; out: number }>; ]
byRoute(windowSeconds?: number): Map<string, { in: number; out: number }>;
byIP(windowSeconds?: number): Map<string, { in: number; out: number }>;
};
// Request metrics
requests: {
perSecond(): number;
perMinute(): number;
total(): number;
};
// Cumulative totals
totals: {
bytesIn(): number;
bytesOut(): number;
connections(): number;
};
// Performance metrics
percentiles: {
connectionDuration(): { p50: number; p95: number; p99: number };
bytesTransferred(): {
in: { p50: number; p95: number; p99: number };
out: { p50: number; p95: number; p99: number };
};
};
}
```
## 3. Implementation Plan
### Current Status
- **Phase 1**: ~90% complete (core functionality implemented, tests need fixing)
- **Phase 2**: ~60% complete (main features done, percentiles pending)
- **Phase 3**: ~40% complete (basic optimizations in place)
- **Phase 4**: 0% complete (export formats not started)
### Phase 1: Core Throughput Tracking (Week 1)
- [x] Implement `ThroughputTracker` class
- [x] Integrate byte recording into socket data handlers
- [x] Add periodic sampling (1-second intervals)
- [x] Update `getThroughputRate()` to use time-series data (replaced with new clean API)
- [ ] Add unit tests for throughput tracking
### Phase 2: Enhanced Metrics (Week 2)
- [x] Add configurable time windows (1s, 10s, 60s, 5m, etc.)
- [ ] Implement percentile calculations
- [x] Add route-specific and IP-specific throughput tracking
- [x] Create historical data access methods
- [ ] Add integration tests
### Phase 3: Performance Optimization (Week 3)
- [x] Use circular buffers for efficiency
- [ ] Implement data aggregation for longer time windows
- [x] Add configurable retention periods
- [ ] Optimize memory usage
- [ ] Add performance benchmarks
### Phase 4: Export Formats (Week 4)
- [ ] Add Prometheus metric format with proper metric types
- [ ] Add StatsD format support
- [ ] Add JSON export with metadata
- [ ] Create OpenMetrics compatibility
- [ ] Add documentation and examples
## 4. Key Design Decisions
### A. Sampling Strategy
- **1-second samples** for fine-grained data
- **Aggregate to 1-minute** for longer retention
- **Keep 1 hour** of second-level data
- **Keep 24 hours** of minute-level data
### B. Memory Management
- **Circular buffers** for fixed memory usage
- **Configurable retention** periods
- **Lazy aggregation** for older data
- **Efficient data structures** (typed arrays for samples)
### C. Performance Considerations
- **Batch updates** during high throughput
- **Debounced calculations** for expensive metrics
- **Cached results** with TTL
- **Worker thread** option for heavy calculations
## 5. Configuration Options
```typescript
interface IMetricsConfig {
enabled: boolean;
// Sampling configuration
sampleIntervalMs: number; // Default: 1000 (1 second)
retentionSeconds: number; // Default: 3600 (1 hour)
// Performance tuning
enableDetailedTracking: boolean; // Per-connection byte history
enablePercentiles: boolean; // Calculate percentiles
cacheResultsMs: number; // Cache expensive calculations
// Export configuration
prometheusEnabled: boolean;
prometheusPath: string; // Default: /metrics
prometheusPrefix: string; // Default: smartproxy_
}
```
## 6. Example Usage
```typescript
const proxy = new SmartProxy({
metrics: {
enabled: true,
sampleIntervalMs: 1000,
enableDetailedTracking: true
} }
}); }
// Get metrics instance
const metrics = proxy.getMetrics();
// Connection metrics
console.log(`Active connections: ${metrics.connections.active()}`);
console.log(`Total connections: ${metrics.connections.total()}`);
// Throughput metrics
const instant = metrics.throughput.instant();
console.log(`Current: ${instant.in} bytes/sec in, ${instant.out} bytes/sec out`);
const recent = metrics.throughput.recent(); // Last 10 seconds
const average = metrics.throughput.average(); // Last 60 seconds
// Custom time window
const custom = metrics.throughput.custom(30); // Last 30 seconds
// Historical data for graphing
const history = metrics.throughput.history(300); // Last 5 minutes
history.forEach(point => {
console.log(`${new Date(point.timestamp)}: ${point.in} bytes/sec in, ${point.out} bytes/sec out`);
});
// Top routes by throughput
const routeThroughput = metrics.throughput.byRoute(60);
routeThroughput.forEach((stats, route) => {
console.log(`Route ${route}: ${stats.in} bytes/sec in, ${stats.out} bytes/sec out`);
});
// Request metrics
console.log(`RPS: ${metrics.requests.perSecond()}`);
console.log(`RPM: ${metrics.requests.perMinute()}`);
// Totals
console.log(`Total bytes in: ${metrics.totals.bytesIn()}`);
console.log(`Total bytes out: ${metrics.totals.bytesOut()}`);
``` ```
## 7. Prometheus Export Example ## Benefits
1. **DRY Configuration**: No need to duplicate common settings across routes
2. **Flexibility**: Different backends for different ports/paths within same domain
3. **Clarity**: All routing for a domain in one place
4. **Performance**: Single route lookup instead of multiple
5. **Backwards Compatible**: Can migrate gradually
``` ## Migration Strategy
# HELP smartproxy_throughput_bytes_per_second Current throughput in bytes per second 1. Keep support for `target` temporarily with deprecation warning
# TYPE smartproxy_throughput_bytes_per_second gauge 2. Auto-convert `target` to `targets: [target]` internally
smartproxy_throughput_bytes_per_second{direction="in",window="1s"} 1234567 3. Update documentation with migration examples
smartproxy_throughput_bytes_per_second{direction="out",window="1s"} 987654 4. Remove `target` support in next major version
smartproxy_throughput_bytes_per_second{direction="in",window="10s"} 1134567
smartproxy_throughput_bytes_per_second{direction="out",window="10s"} 887654
# HELP smartproxy_bytes_total Total bytes transferred
# TYPE smartproxy_bytes_total counter
smartproxy_bytes_total{direction="in"} 123456789
smartproxy_bytes_total{direction="out"} 98765432
# HELP smartproxy_active_connections Current number of active connections
# TYPE smartproxy_active_connections gauge
smartproxy_active_connections 42
# HELP smartproxy_connection_duration_seconds Connection duration in seconds
# TYPE smartproxy_connection_duration_seconds histogram
smartproxy_connection_duration_seconds_bucket{le="0.1"} 100
smartproxy_connection_duration_seconds_bucket{le="1"} 500
smartproxy_connection_duration_seconds_bucket{le="10"} 800
smartproxy_connection_duration_seconds_bucket{le="+Inf"} 850
smartproxy_connection_duration_seconds_sum 4250
smartproxy_connection_duration_seconds_count 850
```
## 8. Migration Strategy
### Breaking Changes
- Completely replace the old metrics API with the new clean design
- Remove all `get*` prefixed methods in favor of grouped properties
- Use simple `{ in, out }` objects instead of verbose property names
- Provide clear migration guide in documentation
### Implementation Approach
1. ✅ Create new `ThroughputTracker` class for time-series data
2. ✅ Implement new `IMetrics` interface with clean API
3. ✅ Replace `MetricsCollector` implementation entirely
4. ✅ Update all references to use new API
5. ⚠️ Add comprehensive tests for accuracy validation (partial)
### Additional Refactoring Completed
- Refactored all SmartProxy components to use cleaner dependency pattern
- Components now receive only `SmartProxy` instance instead of individual dependencies
- Access to other components via `this.smartProxy.componentName`
- Significantly simplified constructor signatures across the codebase
## 9. Success Metrics
- **Accuracy**: Throughput metrics accurate within 1% of actual
- **Performance**: < 1% CPU overhead for metrics collection
- **Memory**: < 10MB memory usage for 1 hour of data
- **Latency**: < 1ms to retrieve any metric
- **Reliability**: No metrics data loss under load
## 10. Future Enhancements
### Phase 5: Advanced Analytics
- Anomaly detection for traffic patterns
- Predictive analytics for capacity planning
- Correlation analysis between routes
- Real-time alerting integration
### Phase 6: Distributed Metrics
- Metrics aggregation across multiple proxies
- Distributed time-series storage
- Cross-proxy analytics
- Global dashboard support
## 11. Risks and Mitigations
### Risk: Memory Usage
- **Mitigation**: Circular buffers and configurable retention
- **Monitoring**: Track memory usage per metric type
### Risk: Performance Impact
- **Mitigation**: Efficient data structures and caching
- **Testing**: Load test with metrics enabled/disabled
### Risk: Data Accuracy
- **Mitigation**: Atomic operations and proper synchronization
- **Validation**: Compare with external monitoring tools
## Conclusion
This plan transforms SmartProxy's metrics from a basic cumulative system to a comprehensive, time-series based monitoring solution suitable for production environments. The phased approach ensures minimal disruption while delivering immediate value through accurate throughput measurements.

2749
test-output.log Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -32,14 +32,14 @@ tap.test('PathMatcher - wildcard matching', async () => {
const result = PathMatcher.match('/api/*', '/api/users/123/profile'); const result = PathMatcher.match('/api/*', '/api/users/123/profile');
expect(result.matches).toEqual(true); expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash
expect(result.pathRemainder).toEqual('users/123/profile'); expect(result.pathRemainder).toEqual('/users/123/profile');
}); });
tap.test('PathMatcher - mixed parameters and wildcards', async () => { tap.test('PathMatcher - mixed parameters and wildcards', async () => {
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123'); const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
expect(result.matches).toEqual(true); expect(result.matches).toEqual(true);
expect(result.params).toEqual({ version: 'v1' }); expect(result.params).toEqual({ version: 'v1' });
expect(result.pathRemainder).toEqual('users/123'); expect(result.pathRemainder).toEqual('/users/123');
}); });
tap.test('PathMatcher - trailing slash normalization', async () => { tap.test('PathMatcher - trailing slash normalization', async () => {

View File

@@ -58,7 +58,7 @@ tap.test('Shared Security Manager', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'target.com', port: 443 } targets: [{ host: 'target.com', port: 443 }]
}, },
security: { security: {
ipAllowList: ['10.0.0.*', '192.168.1.*'], ipAllowList: ['10.0.0.*', '192.168.1.*'],
@@ -113,7 +113,7 @@ tap.test('Shared Security Manager', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'target.com', port: 443 } targets: [{ host: 'target.com', port: 443 }]
}, },
security: { security: {
rateLimit: { rateLimit: {

View File

@@ -59,7 +59,7 @@ tap.test('should create ACME challenge route', async (tools) => {
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 8080 } targets: [{ host: 'localhost', port: 8080 }]
} }
}, },
challengeRoute challengeRoute

View File

@@ -18,7 +18,7 @@ tap.test('should defer certificate provisioning until ports are ready', async (t
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 }, targets: [{ host: 'localhost', port: 8181 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -30,7 +30,7 @@ tap.test('should defer certificate provisioning until after ports are listening'
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 }, targets: [{ host: 'localhost', port: 8181 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -126,7 +126,7 @@ tap.test('should have ACME challenge route ready before certificate provisioning
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 }, targets: [{ host: 'localhost', port: 8181 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'

View File

@@ -16,10 +16,10 @@ tap.test('SmartCertManager should call getCertificateForDomain with wildcard opt
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -0,0 +1,360 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import type { TSmartProxyCertProvisionObject } from '../ts/index.js';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let testProxy: SmartProxy;
// Load test certificates from helpers
const testCert = fs.readFileSync(path.join(__dirname, 'helpers/test-cert.pem'), 'utf8');
const testKey = fs.readFileSync(path.join(__dirname, 'helpers/test-key.pem'), 'utf8');
tap.test('SmartProxy should support custom certificate provision function', async () => {
// Create test certificate object matching ICert interface
const testCertObject = {
id: 'test-cert-1',
domainName: 'test.example.com',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days
privateKey: testKey,
publicKey: testCert,
csr: ''
};
// Custom certificate store for testing
const customCerts = new Map<string, typeof testCertObject>();
customCerts.set('test.example.com', testCertObject);
// Create proxy with custom certificate provision
testProxy = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
console.log(`Custom cert provision called for domain: ${domain}`);
// Return custom cert for known domains
if (customCerts.has(domain)) {
console.log(`Returning custom certificate for ${domain}`);
return customCerts.get(domain)!;
}
// Fallback to Let's Encrypt for other domains
console.log(`Falling back to Let's Encrypt for ${domain}`);
return 'http01';
},
certProvisionFallbackToAcme: true,
acme: {
email: 'test@example.com',
useProduction: false
},
routes: [
{
name: 'test-route',
match: {
ports: [443],
domains: ['test.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
expect(testProxy).toBeInstanceOf(SmartProxy);
});
tap.test('Custom certificate provision function should be called', async () => {
let provisionCalled = false;
const provisionedDomains: string[] = [];
const testProxy2 = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
provisionCalled = true;
provisionedDomains.push(domain);
// Return a test certificate matching ICert interface
return {
id: `test-cert-${domain}`,
domainName: domain,
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: testKey,
publicKey: testCert,
csr: ''
};
},
acme: {
email: 'test@example.com',
useProduction: false,
port: 9080
},
routes: [
{
name: 'custom-cert-route',
match: {
ports: [9443],
domains: ['custom.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock the certificate manager to test our custom provision function
let certManagerCalled = false;
const origCreateCertManager = (testProxy2 as any).createCertificateManager;
(testProxy2 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy2, args);
// Override provisionAllCertificates to track calls
const origProvisionAll = certManager.provisionAllCertificates;
certManager.provisionAllCertificates = async function() {
certManagerCalled = true;
await origProvisionAll.call(certManager);
};
return certManager;
};
// Start the proxy (this will trigger certificate provisioning)
await testProxy2.start();
expect(certManagerCalled).toBeTrue();
expect(provisionCalled).toBeTrue();
expect(provisionedDomains).toContain('custom.example.com');
await testProxy2.stop();
});
tap.test('Should fallback to ACME when custom provision fails', async () => {
const failedDomains: string[] = [];
let acmeAttempted = false;
const testProxy3 = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
failedDomains.push(domain);
throw new Error('Custom provision failed for testing');
},
certProvisionFallbackToAcme: true,
acme: {
email: 'test@example.com',
useProduction: false,
port: 9080
},
routes: [
{
name: 'fallback-route',
match: {
ports: [9444],
domains: ['fallback.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock to track ACME attempts
const origCreateCertManager = (testProxy3 as any).createCertificateManager;
(testProxy3 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy3, args);
// Mock SmartAcme to avoid real ACME calls
(certManager as any).smartAcme = {
getCertificateForDomain: async () => {
acmeAttempted = true;
throw new Error('Mocked ACME failure');
}
};
return certManager;
};
// Start the proxy
await testProxy3.start();
// Custom provision should have failed
expect(failedDomains).toContain('fallback.example.com');
// ACME should have been attempted as fallback
expect(acmeAttempted).toBeTrue();
await testProxy3.stop();
});
tap.test('Should not fallback when certProvisionFallbackToAcme is false', async () => {
let errorThrown = false;
let errorMessage = '';
const testProxy4 = new SmartProxy({
certProvisionFunction: async (_domain: string): Promise<TSmartProxyCertProvisionObject> => {
throw new Error('Custom provision failed for testing');
},
certProvisionFallbackToAcme: false,
routes: [
{
name: 'no-fallback-route',
match: {
ports: [9445],
domains: ['no-fallback.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock certificate manager to capture errors
const origCreateCertManager = (testProxy4 as any).createCertificateManager;
(testProxy4 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy4, args);
// Override provisionAllCertificates to capture errors
const origProvisionAll = certManager.provisionAllCertificates;
certManager.provisionAllCertificates = async function() {
try {
await origProvisionAll.call(certManager);
} catch (e) {
errorThrown = true;
errorMessage = e.message;
throw e;
}
};
return certManager;
};
try {
await testProxy4.start();
} catch (e) {
// Expected to fail
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toInclude('Custom provision failed for testing');
await testProxy4.stop();
});
tap.test('Should return http01 for unknown domains', async () => {
let returnedHttp01 = false;
let acmeAttempted = false;
const testProxy5 = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
if (domain === 'known.example.com') {
return {
id: `test-cert-${domain}`,
domainName: domain,
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: testKey,
publicKey: testCert,
csr: ''
};
}
returnedHttp01 = true;
return 'http01';
},
acme: {
email: 'test@example.com',
useProduction: false,
port: 9081
},
routes: [
{
name: 'unknown-domain-route',
match: {
ports: [9446],
domains: ['unknown.example.com']
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: 8080
}],
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
}
]
});
// Mock to track ACME attempts
const origCreateCertManager = (testProxy5 as any).createCertificateManager;
(testProxy5 as any).createCertificateManager = async function(...args: any[]) {
const certManager = await origCreateCertManager.apply(testProxy5, args);
// Mock SmartAcme to track attempts
(certManager as any).smartAcme = {
getCertificateForDomain: async () => {
acmeAttempted = true;
throw new Error('Mocked ACME failure');
}
};
return certManager;
};
await testProxy5.start();
// Should have returned http01 for unknown domain
expect(returnedHttp01).toBeTrue();
// ACME should have been attempted
expect(acmeAttempted).toBeTrue();
await testProxy5.stop();
});
tap.test('cleanup', async () => {
// Clean up any test proxies
if (testProxy) {
await testProxy.stop();
}
});
export default tap.start();

View File

@@ -7,7 +7,7 @@ const testProxy = new SmartProxy({
match: { ports: 9443, domains: 'test.local' }, match: { ports: 9443, domains: 'test.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -67,7 +67,7 @@ tap.test('should handle static certificates', async () => {
match: { ports: 9444, domains: 'static.example.com' }, match: { ports: 9444, domains: 'static.example.com' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: { certificate: {
@@ -96,7 +96,7 @@ tap.test('should handle ACME challenge routes', async () => {
match: { ports: 9445, domains: 'acme.local' }, match: { ports: 9445, domains: 'acme.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -112,7 +112,7 @@ tap.test('should handle ACME challenge routes', async () => {
match: { ports: 9081, domains: 'acme.local' }, match: { ports: 9081, domains: 'acme.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 } targets: [{ host: 'localhost', port: 8080 }]
} }
}], }],
acme: { acme: {
@@ -167,7 +167,7 @@ tap.test('should renew certificates', async () => {
match: { ports: 9446, domains: 'renew.local' }, match: { ports: 9446, domains: 'renew.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -8,7 +8,7 @@ tap.test('should create SmartProxy with certificate routes', async () => {
match: { ports: 8443, domains: 'test.example.com' }, match: { ports: 8443, domains: 'test.example.com' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 }, targets: [{ host: 'localhost', port: 8080 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -13,7 +13,7 @@ tap.test('cleanup queue bug - verify queue processing handles more than batch si
match: { ports: 8588 }, match: { ports: 8588 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9996 } targets: [{ host: 'localhost', port: 9996 }]
} }
}], }],
enableDetailedLogging: false, enableDetailedLogging: false,

View File

@@ -18,10 +18,10 @@ tap.test('should handle clients that connect and immediately disconnect without
match: { ports: 8560 }, match: { ports: 8560 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port port: 9999 // Non-existent port
} }]
} }
}] }]
}); });
@@ -173,10 +173,10 @@ tap.test('should handle clients that error during connection', async () => {
match: { ports: 8561 }, match: { ports: 8561 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 port: 9999
} }]
} }
}] }]
}); });

View File

@@ -20,10 +20,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
match: { ports: 8570 }, match: { ports: 8570 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port port: 9999 // Non-existent port
} }]
} }
}, },
{ {
@@ -31,10 +31,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
match: { ports: 8571 }, match: { ports: 8571 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port port: 9999 // Non-existent port
}, }],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }
@@ -215,10 +215,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
action: { action: {
type: 'forward', type: 'forward',
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 port: 9999
} }]
} }
}] }]
}); });

View File

@@ -65,10 +65,10 @@ tap.test('should forward TCP connections correctly', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7001, port: 7001,
}, }],
}, },
}, },
], ],
@@ -118,10 +118,10 @@ tap.test('should handle TLS passthrough correctly', async () => {
tls: { tls: {
mode: 'passthrough', mode: 'passthrough',
}, },
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: 7002,
}, }],
}, },
}, },
], ],
@@ -179,10 +179,10 @@ tap.test('should handle SNI-based forwarding', async () => {
tls: { tls: {
mode: 'passthrough', mode: 'passthrough',
}, },
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: 7002,
}, }],
}, },
}, },
{ {
@@ -197,10 +197,10 @@ tap.test('should handle SNI-based forwarding', async () => {
tls: { tls: {
mode: 'passthrough', mode: 'passthrough',
}, },
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: 7002,
}, }],
}, },
}, },
], ],

View File

@@ -0,0 +1,299 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { HttpProxy } from '../ts/proxies/http-proxy/index.js';
let testServer: net.Server;
let smartProxy: SmartProxy;
let httpProxy: HttpProxy;
const TEST_SERVER_PORT = 5100;
const PROXY_PORT = 5101;
const HTTP_PROXY_PORT = 5102;
// Track all created servers and connections for cleanup
const allServers: net.Server[] = [];
const allProxies: (SmartProxy | HttpProxy)[] = [];
const activeConnections: net.Socket[] = [];
// Helper: Creates a test TCP server
function createTestServer(port: number): Promise<net.Server> {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
socket.on('data', (data) => {
socket.write(`Echo: ${data.toString()}`);
});
socket.on('error', () => {});
});
server.listen(port, 'localhost', () => {
console.log(`[Test Server] Listening on localhost:${port}`);
allServers.push(server);
resolve(server);
});
});
}
// Helper: Creates multiple concurrent connections
async function createConcurrentConnections(
port: number,
count: number,
fromIP?: string
): Promise<net.Socket[]> {
const connections: net.Socket[] = [];
const promises: Promise<net.Socket>[] = [];
for (let i = 0; i < count; i++) {
promises.push(
new Promise((resolve, reject) => {
const client = new net.Socket();
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`Connection ${i} timeout`));
}, 5000);
client.connect(port, 'localhost', () => {
clearTimeout(timeout);
activeConnections.push(client);
connections.push(client);
resolve(client);
});
client.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
})
);
}
await Promise.all(promises);
return connections;
}
// Helper: Clean up connections
function cleanupConnections(connections: net.Socket[]): void {
connections.forEach(conn => {
if (!conn.destroyed) {
conn.destroy();
}
});
}
tap.test('Setup test environment', async () => {
testServer = await createTestServer(TEST_SERVER_PORT);
// Create SmartProxy with low connection limits for testing
smartProxy = new SmartProxy({
routes: [{
name: 'test-route',
match: {
ports: PROXY_PORT
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}]
},
security: {
maxConnections: 5 // Low limit for testing
}
}],
maxConnectionsPerIP: 3, // Low per-IP limit
connectionRateLimitPerMinute: 10, // Low rate limit
defaults: {
security: {
maxConnections: 10 // Low global limit
}
}
});
await smartProxy.start();
allProxies.push(smartProxy);
});
tap.test('Per-IP connection limits', async () => {
// Test that we can create up to the per-IP limit
const connections1 = await createConcurrentConnections(PROXY_PORT, 3);
expect(connections1.length).toEqual(3);
// Try to create one more connection - should fail
try {
await createConcurrentConnections(PROXY_PORT, 1);
expect.fail('Should not allow more than 3 connections per IP');
} catch (err) {
expect(err.message).toInclude('ECONNRESET');
}
// Clean up first set of connections
cleanupConnections(connections1);
await new Promise(resolve => setTimeout(resolve, 100));
// Should be able to create new connections after cleanup
const connections2 = await createConcurrentConnections(PROXY_PORT, 2);
expect(connections2.length).toEqual(2);
cleanupConnections(connections2);
});
tap.test('Route-level connection limits', async () => {
// Create multiple connections up to route limit
const connections = await createConcurrentConnections(PROXY_PORT, 5);
expect(connections.length).toEqual(5);
// Try to exceed route limit
try {
await createConcurrentConnections(PROXY_PORT, 1);
expect.fail('Should not allow more than 5 connections for this route');
} catch (err) {
expect(err.message).toInclude('ECONNRESET');
}
cleanupConnections(connections);
});
tap.test('Connection rate limiting', async () => {
// Create connections rapidly
const connections: net.Socket[] = [];
// Create 10 connections rapidly (at rate limit)
for (let i = 0; i < 10; i++) {
try {
const conn = await createConcurrentConnections(PROXY_PORT, 1);
connections.push(...conn);
// Small delay to avoid per-IP limit
if (connections.length >= 3) {
cleanupConnections(connections.splice(0, 3));
await new Promise(resolve => setTimeout(resolve, 50));
}
} catch (err) {
// Expected to fail at some point due to rate limit
expect(i).toBeGreaterThan(0);
break;
}
}
cleanupConnections(connections);
});
tap.test('HttpProxy per-IP validation', async () => {
// Create HttpProxy
httpProxy = new HttpProxy({
port: HTTP_PROXY_PORT,
maxConnectionsPerIP: 2,
connectionRateLimitPerMinute: 10,
routes: []
});
await httpProxy.start();
allProxies.push(httpProxy);
// Update SmartProxy to use HttpProxy for TLS termination
await smartProxy.stop();
smartProxy = new SmartProxy({
routes: [{
name: 'https-route',
match: {
ports: PROXY_PORT + 10
},
action: {
type: 'forward',
targets: [{
host: 'localhost',
port: TEST_SERVER_PORT
}],
tls: {
mode: 'terminate'
}
}
}],
useHttpProxy: [PROXY_PORT + 10],
httpProxyPort: HTTP_PROXY_PORT,
maxConnectionsPerIP: 3
});
await smartProxy.start();
// Test that HttpProxy enforces its own per-IP limits
const connections = await createConcurrentConnections(PROXY_PORT + 10, 2);
expect(connections.length).toEqual(2);
// Should reject additional connections
try {
await createConcurrentConnections(PROXY_PORT + 10, 1);
expect.fail('HttpProxy should enforce per-IP limits');
} catch (err) {
expect(err.message).toInclude('ECONNRESET');
}
cleanupConnections(connections);
});
tap.test('IP tracking cleanup', async (tools) => {
// Create and close many connections from different IPs
const connections: net.Socket[] = [];
for (let i = 0; i < 5; i++) {
const conn = await createConcurrentConnections(PROXY_PORT, 1);
connections.push(...conn);
}
// Close all connections
cleanupConnections(connections);
// Wait for cleanup interval (set to 60s in production, but we'll check immediately)
await tools.delayFor(100);
// Verify that IP tracking has been cleaned up
const securityManager = (smartProxy as any).securityManager;
const ipCount = (securityManager.connectionsByIP as Map<string, any>).size;
// Should have no IPs tracked after cleanup
expect(ipCount).toEqual(0);
});
tap.test('Cleanup queue race condition handling', async () => {
// Create many connections concurrently to trigger batched cleanup
const promises: Promise<net.Socket[]>[] = [];
for (let i = 0; i < 20; i++) {
promises.push(createConcurrentConnections(PROXY_PORT, 1).catch(() => []));
}
const results = await Promise.all(promises);
const allConnections = results.flat();
// Close all connections rapidly
allConnections.forEach(conn => conn.destroy());
// Give cleanup queue time to process
await new Promise(resolve => setTimeout(resolve, 500));
// Verify all connections were cleaned up
const connectionManager = (smartProxy as any).connectionManager;
const remainingConnections = connectionManager.getConnectionCount();
expect(remainingConnections).toEqual(0);
});
tap.test('Cleanup and shutdown', async () => {
// Clean up any remaining connections
cleanupConnections(activeConnections);
activeConnections.length = 0;
// Stop all proxies
for (const proxy of allProxies) {
await proxy.stop();
}
allProxies.length = 0;
// Close all test servers
for (const server of allServers) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
allServers.length = 0;
});
tap.start();

View File

@@ -9,7 +9,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
match: { ports: [18443], domains: ['test.local'] }, match: { ports: [18443], domains: ['test.local'] },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3000 }, targets: [{ host: 'localhost', port: 3000 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -63,7 +63,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
match: { ports: [18444], domains: ['test2.local'] }, match: { ports: [18444], domains: ['test2.local'] },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -37,7 +37,7 @@ tap.test('regular forward route should work correctly', async () => {
match: { ports: 7890 }, match: { ports: 7890 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 6789 } targets: [{ host: 'localhost', port: 6789 }]
} }
}] }]
}); });
@@ -106,7 +106,7 @@ tap.skip.test('NFTables forward route should not terminate connections (requires
action: { action: {
type: 'forward', type: 'forward',
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
target: { host: 'localhost', port: 6789 } targets: [{ host: 'localhost', port: 6789 }]
} }
}] }]
}); });

View File

@@ -39,10 +39,10 @@ tap.test('forward connections should not be immediately closed', async (t) => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9090, port: 9090,
}, }],
}, },
}, },
], ],

View File

@@ -39,7 +39,7 @@ tap.test('Route Helpers - Create HTTP routes', async () => {
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 }); const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('example.com'); expect(route.match.domains).toEqual('example.com');
expect(route.action.target).toEqual({ host: 'localhost', port: 3000 }); expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
}); });
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => { tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {

View File

@@ -20,7 +20,7 @@ tap.test('should forward non-TLS connections on HttpProxy ports', async (tapTest
match: { ports: testPort }, match: { ports: testPort },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}; };
@@ -81,7 +81,7 @@ tap.test('should use direct connection for non-HttpProxy ports', async (tapTest)
match: { ports: 8080 }, // Not in useHttpProxy match: { ports: 8080 }, // Not in useHttpProxy
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}; };
@@ -142,7 +142,7 @@ tap.test('should handle ACME HTTP-01 challenges on port 80 with HttpProxy', asyn
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8080 } targets: [{ host: 'localhost', port: 8080 }]
} }
}] }]
}; };

View File

@@ -14,7 +14,7 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
match: { ports: 8080 }, match: { ports: 8080 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}; };
@@ -140,7 +140,7 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
match: { ports: 443 }, match: { ports: 443 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8443 }, targets: [{ host: 'localhost', port: 8443 }],
tls: { mode: 'terminate' } tls: { mode: 'terminate' }
} }
}] }]

View File

@@ -17,7 +17,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
match: { ports: 8081 }, match: { ports: 8081 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8181 } targets: [{ host: 'localhost', port: 8181 }]
} }
}] }]
}); });
@@ -120,7 +120,7 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
}] }]
}); });

View File

@@ -42,7 +42,7 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
}] }]
}); });
@@ -131,7 +131,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
}] }]
}); });

View File

@@ -67,7 +67,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort }, targets: [{ host: 'localhost', port: targetPort }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' // Use ACME for certificate certificate: 'auto' // Use ACME for certificate
@@ -83,7 +83,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
} }
], ],
@@ -191,7 +191,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: targetPort } targets: [{ host: 'localhost', port: targetPort }]
} }
} }
]; ];

View File

@@ -0,0 +1,120 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SecurityManager } from '../ts/proxies/http-proxy/security-manager.js';
import { createLogger } from '../ts/proxies/http-proxy/models/types.js';
let securityManager: SecurityManager;
const logger = createLogger('error'); // Quiet logger for tests
tap.test('Setup HttpProxy SecurityManager', async () => {
securityManager = new SecurityManager(logger, [], 3, 10); // Low limits for testing
});
tap.test('HttpProxy IP connection tracking', async () => {
const testIP = '10.0.0.1';
// Track connections
securityManager.trackConnectionByIP(testIP, 'http-conn1');
securityManager.trackConnectionByIP(testIP, 'http-conn2');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(2);
// Validate IP should pass
let result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Add one more to reach limit
securityManager.trackConnectionByIP(testIP, 'http-conn3');
// Should now reject new connections
result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Maximum connections per IP (3) exceeded');
// Remove a connection
securityManager.removeConnectionByIP(testIP, 'http-conn1');
// Should allow connections again
result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Clean up
securityManager.removeConnectionByIP(testIP, 'http-conn2');
securityManager.removeConnectionByIP(testIP, 'http-conn3');
});
tap.test('HttpProxy connection rate limiting', async () => {
const testIP = '10.0.0.2';
// Make 10 connections rapidly (at rate limit)
for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Track the connection to simulate real usage
securityManager.trackConnectionByIP(testIP, `rate-conn${i}`);
}
// 11th connection should be rate limited
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit (10/min) exceeded');
// Clean up
for (let i = 0; i < 10; i++) {
securityManager.removeConnectionByIP(testIP, `rate-conn${i}`);
}
});
tap.test('HttpProxy CLIENT_IP header handling', async () => {
// This tests the scenario where SmartProxy forwards the real client IP
const realClientIP = '203.0.113.1';
const proxyIP = '127.0.0.1';
// Simulate SmartProxy tracking the real client IP
securityManager.trackConnectionByIP(realClientIP, 'forwarded-conn1');
securityManager.trackConnectionByIP(realClientIP, 'forwarded-conn2');
securityManager.trackConnectionByIP(realClientIP, 'forwarded-conn3');
// Real client IP should be at limit
let result = securityManager.validateIP(realClientIP);
expect(result.allowed).toBeFalse();
// But proxy IP should still be allowed
result = securityManager.validateIP(proxyIP);
expect(result.allowed).toBeTrue();
// Clean up
securityManager.removeConnectionByIP(realClientIP, 'forwarded-conn1');
securityManager.removeConnectionByIP(realClientIP, 'forwarded-conn2');
securityManager.removeConnectionByIP(realClientIP, 'forwarded-conn3');
});
tap.test('HttpProxy automatic cleanup', async (tools) => {
const testIP = '10.0.0.3';
// Create and immediately remove connections
for (let i = 0; i < 5; i++) {
securityManager.trackConnectionByIP(testIP, `cleanup-conn${i}`);
securityManager.removeConnectionByIP(testIP, `cleanup-conn${i}`);
}
// Add rate limit entries
for (let i = 0; i < 5; i++) {
securityManager.validateIP(testIP);
}
// Wait a bit (cleanup runs every 60 seconds in production)
// For testing, we'll just verify the cleanup logic works
await tools.delayFor(100);
// Manually trigger cleanup (in production this happens automatically)
(securityManager as any).performIpCleanup();
// IP should be cleaned up
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(0);
});
tap.test('Cleanup HttpProxy SecurityManager', async () => {
securityManager.clearIPTracking();
});
tap.start();

View File

@@ -95,10 +95,10 @@ tap.test('should support static host/port routes', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: serverPort port: serverPort
} }]
} }
} }
]; ];
@@ -135,13 +135,13 @@ tap.test('should support function-based host', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: (context: IRouteContext) => { host: (context: IRouteContext) => {
// Return localhost always in this test // Return localhost always in this test
return 'localhost'; return 'localhost';
}, },
port: serverPort port: serverPort
} }]
} }
} }
]; ];
@@ -178,13 +178,13 @@ tap.test('should support function-based port', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: (context: IRouteContext) => { port: (context: IRouteContext) => {
// Return test server port // Return test server port
return serverPort; return serverPort;
} }
} }]
} }
} }
]; ];
@@ -221,14 +221,14 @@ tap.test('should support function-based host AND port', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: (context: IRouteContext) => { host: (context: IRouteContext) => {
return 'localhost'; return 'localhost';
}, },
port: (context: IRouteContext) => { port: (context: IRouteContext) => {
return serverPort; return serverPort;
} }
} }]
} }
} }
]; ];
@@ -265,7 +265,7 @@ tap.test('should support context-based routing with path', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: (context: IRouteContext) => { host: (context: IRouteContext) => {
// Use path to determine host // Use path to determine host
if (context.path?.startsWith('/api')) { if (context.path?.startsWith('/api')) {
@@ -275,7 +275,7 @@ tap.test('should support context-based routing with path', async () => {
} }
}, },
port: serverPort port: serverPort
} }]
} }
} }
]; ];

View File

@@ -232,10 +232,10 @@ tap.test('should start the proxy server', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3100 port: 3100
}, }],
tls: { tls: {
mode: 'terminate' mode: 'terminate'
}, },

View File

@@ -40,7 +40,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
match: { ports: 8590 }, match: { ports: 8590 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9998 } targets: [{ host: 'localhost', port: 9998 }]
} }
}], }],
keepAlive: true, keepAlive: true,
@@ -117,7 +117,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
match: { ports: 8591 }, match: { ports: 8591 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9998 } targets: [{ host: 'localhost', port: 9998 }]
} }
}], }],
keepAlive: true, keepAlive: true,
@@ -178,7 +178,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
match: { ports: 8592 }, match: { ports: 8592 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9998 } targets: [{ host: 'localhost', port: 9998 }]
} }
}], }],
keepAlive: true, keepAlive: true,

View File

@@ -0,0 +1,112 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { LogDeduplicator } from '../ts/core/utils/log-deduplicator.js';
let deduplicator: LogDeduplicator;
tap.test('Setup log deduplicator', async () => {
deduplicator = new LogDeduplicator(1000); // 1 second flush interval for testing
});
tap.test('Connection rejection deduplication', async (tools) => {
// Simulate multiple connection rejections
for (let i = 0; i < 10; i++) {
deduplicator.log(
'connection-rejected',
'warn',
'Connection rejected',
{ reason: 'global-limit', component: 'test' },
'global-limit'
);
}
for (let i = 0; i < 5; i++) {
deduplicator.log(
'connection-rejected',
'warn',
'Connection rejected',
{ reason: 'route-limit', component: 'test' },
'route-limit'
);
}
// Force flush
deduplicator.flush('connection-rejected');
// The logs should have been aggregated
// (Can't easily test the actual log output, but we can verify the mechanism works)
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('IP rejection deduplication', async (tools) => {
// Simulate rejections from multiple IPs
const ips = ['192.168.1.100', '192.168.1.101', '192.168.1.100', '10.0.0.1'];
const reasons = ['per-ip-limit', 'rate-limit', 'per-ip-limit', 'global-limit'];
for (let i = 0; i < ips.length; i++) {
deduplicator.log(
'ip-rejected',
'warn',
`Connection rejected from ${ips[i]}`,
{ remoteIP: ips[i], reason: reasons[i] },
ips[i]
);
}
// Add more rejections from the same IP
for (let i = 0; i < 20; i++) {
deduplicator.log(
'ip-rejected',
'warn',
'Connection rejected from 192.168.1.100',
{ remoteIP: '192.168.1.100', reason: 'rate-limit' },
'192.168.1.100'
);
}
// Force flush
deduplicator.flush('ip-rejected');
// Verify the deduplicator exists and works
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Connection cleanup deduplication', async (tools) => {
// Simulate various cleanup events
const reasons = ['normal', 'timeout', 'error', 'normal', 'zombie'];
for (const reason of reasons) {
for (let i = 0; i < 5; i++) {
deduplicator.log(
'connection-cleanup',
'info',
`Connection cleanup: ${reason}`,
{ connectionId: `conn-${i}`, reason },
reason
);
}
}
// Wait for automatic flush
await tools.delayFor(1500);
// Verify deduplicator is working
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Automatic periodic flush', async (tools) => {
// Add some events
deduplicator.log('test-event', 'info', 'Test message', {}, 'test');
// Wait for automatic flush (should happen within 2x flush interval = 2 seconds)
await tools.delayFor(2500);
// Events should have been flushed automatically
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Cleanup deduplicator', async () => {
deduplicator.cleanup();
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.start();

View File

@@ -39,10 +39,10 @@ tap.test('setup test environment', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9876 port: 9876
} }]
// No TLS configuration - just plain TCP forwarding // No TLS configuration - just plain TCP forwarding
} }
}], }],

View File

@@ -29,7 +29,7 @@ tap.test('MetricsCollector provides accurate metrics', async (tools) => {
match: { ports: 8700 }, match: { ports: 8700 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9995 } targets: [{ host: 'localhost', port: 9995 }]
} }
}, },
{ {
@@ -37,7 +37,7 @@ tap.test('MetricsCollector provides accurate metrics', async (tools) => {
match: { ports: 8701 }, match: { ports: 8701 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9995 } targets: [{ host: 'localhost', port: 9995 }]
} }
} }
], ],

View File

@@ -36,10 +36,10 @@ tap.test('should create SmartProxy instance with new metrics', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: echoServerPort port: echoServerPort
}, }],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }

View File

@@ -34,10 +34,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
action: { action: {
type: 'forward', type: 'forward',
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8001, port: 8001,
}, }],
}, },
}, },
// Also add regular forwarding route for comparison // Also add regular forwarding route for comparison
@@ -49,10 +49,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8001, port: 8001,
}, }],
}, },
}, },
], ],

View File

@@ -42,10 +42,10 @@ const sampleRoute: IRouteConfig = {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8000 port: 8000
}, }],
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
nftables: { nftables: {
protocol: 'tcp', protocol: 'tcp',
@@ -115,10 +115,10 @@ tap.skip.test('NFTablesManager route updating test', async () => {
...sampleRoute, ...sampleRoute,
action: { action: {
...sampleRoute.action, ...sampleRoute.action,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9000 // Different port port: 9000 // Different port
}, }],
nftables: { nftables: {
...sampleRoute.action.nftables, ...sampleRoute.action.nftables,
protocol: 'all' // Different protocol protocol: 'all' // Different protocol
@@ -147,10 +147,10 @@ tap.skip.test('NFTablesManager route deprovisioning test', async () => {
...sampleRoute, ...sampleRoute,
action: { action: {
...sampleRoute.action, ...sampleRoute.action,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9000 // Different port from original test port: 9000 // Different port from original test
}, }],
nftables: { nftables: {
...sampleRoute.action.nftables, ...sampleRoute.action.nftables,
protocol: 'all' // Different protocol from original test protocol: 'all' // Different protocol from original test

View File

@@ -91,7 +91,7 @@ testFn('SmartProxy getNfTablesStatus functionality', async () => {
match: { ports: 3004 }, match: { ports: 3004 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3005 } targets: [{ host: 'localhost', port: 3005 }]
} }
} }
] ]

View File

@@ -29,7 +29,7 @@ tap.test('port forwarding should not immediately close connections', async (tool
match: { ports: 9999 }, match: { ports: 9999 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 8888 } targets: [{ host: 'localhost', port: 8888 }]
} }
}] }]
}); });
@@ -63,7 +63,7 @@ tap.test('TLS passthrough should work correctly', async () => {
action: { action: {
type: 'forward', type: 'forward',
tls: { mode: 'passthrough' }, tls: { mode: 'passthrough' },
target: { host: 'localhost', port: 443 } targets: [{ host: 'localhost', port: 443 }]
} }
}] }]
}); });

View File

@@ -214,12 +214,12 @@ tap.test('should handle errors in port mapping functions', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: () => { port: () => {
throw new Error('Test error in port mapping function'); throw new Error('Test error in port mapping function');
} }
} }]
}, },
name: 'Error Route' name: 'Error Route'
}; };

View File

@@ -21,7 +21,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3000 } targets: [{ host: 'localhost', port: 3000 }]
} }
}, },
{ {
@@ -31,7 +31,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate' as const, mode: 'terminate' as const,
certificate: 'auto' as const certificate: 'auto' as const
@@ -153,7 +153,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3000 } targets: [{ host: 'localhost', port: 3000 }]
} }
}, },
{ {
@@ -163,7 +163,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate' as const, mode: 'terminate' as const,
certificate: 'auto' as const certificate: 'auto' as const

View File

@@ -15,10 +15,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'httpbin.org', host: 'httpbin.org',
port: 443 port: 443
} }]
} }
} }
], ],
@@ -45,10 +45,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8002 port: 8002
}, }],
sendProxyProtocol: true sendProxyProtocol: true
} }
} }

View File

@@ -32,10 +32,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
match: { ports: 8591 }, match: { ports: 8591 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9998 // Backend that closes immediately port: 9998 // Backend that closes immediately
} }]
} }
}] }]
}); });
@@ -50,10 +50,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
match: { ports: 8590 }, match: { ports: 8590 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8591 // Forward to proxy2 port: 8591 // Forward to proxy2
} }]
} }
}] }]
}); });

View File

@@ -19,10 +19,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
match: { ports: 8581 }, match: { ports: 8581 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent backend port: 9999 // Non-existent backend
} }]
} }
}] }]
}); });
@@ -37,10 +37,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
match: { ports: 8580 }, match: { ports: 8580 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8581 // Forward to proxy2 port: 8581 // Forward to proxy2
} }]
} }
}] }]
}); });
@@ -270,10 +270,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
match: { ports: 8583 }, match: { ports: 8583 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent backend port: 9999 // Non-existent backend
} }]
} }
}] }]
}); });
@@ -289,10 +289,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
match: { ports: 8582 }, match: { ports: 8582 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8583 // Forward to proxy2 port: 8583 // Forward to proxy2
} }]
} }
}] }]
}); });

View File

@@ -19,10 +19,10 @@ tap.test('should handle rapid connection retries without leaking connections', a
match: { ports: 8550 }, match: { ports: 8550 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9999 // Non-existent port to force connection failures port: 9999 // Non-existent port to force connection failures
} }]
} }
}] }]
}); });

View File

@@ -17,7 +17,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3000 }, targets: [{ host: 'localhost', port: 3000 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',
@@ -95,7 +95,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 3001 }, targets: [{ host: 'localhost', port: 3001 }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -56,8 +56,8 @@ tap.test('Routes: Should create basic HTTP route', async () => {
expect(httpRoute.match.ports).toEqual(80); expect(httpRoute.match.ports).toEqual(80);
expect(httpRoute.match.domains).toEqual('example.com'); expect(httpRoute.match.domains).toEqual('example.com');
expect(httpRoute.action.type).toEqual('forward'); expect(httpRoute.action.type).toEqual('forward');
expect(httpRoute.action.target?.host).toEqual('localhost'); expect(httpRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(httpRoute.action.target?.port).toEqual(3000); expect(httpRoute.action.targets?.[0]?.port).toEqual(3000);
expect(httpRoute.name).toEqual('Basic HTTP Route'); expect(httpRoute.name).toEqual('Basic HTTP Route');
}); });
@@ -74,8 +74,8 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
expect(httpsRoute.action.type).toEqual('forward'); expect(httpsRoute.action.type).toEqual('forward');
expect(httpsRoute.action.tls?.mode).toEqual('terminate'); expect(httpsRoute.action.tls?.mode).toEqual('terminate');
expect(httpsRoute.action.tls?.certificate).toEqual('auto'); expect(httpsRoute.action.tls?.certificate).toEqual('auto');
expect(httpsRoute.action.target?.host).toEqual('localhost'); expect(httpsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(httpsRoute.action.target?.port).toEqual(8080); expect(httpsRoute.action.targets?.[0]?.port).toEqual(8080);
expect(httpsRoute.name).toEqual('HTTPS Route'); expect(httpsRoute.name).toEqual('HTTPS Route');
}); });
@@ -131,10 +131,10 @@ tap.test('Routes: Should create load balancer route', async () => {
// Validate the route configuration // Validate the route configuration
expect(lbRoute.match.domains).toEqual('app.example.com'); expect(lbRoute.match.domains).toEqual('app.example.com');
expect(lbRoute.action.type).toEqual('forward'); expect(lbRoute.action.type).toEqual('forward');
expect(Array.isArray(lbRoute.action.target?.host)).toBeTrue(); expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
expect((lbRoute.action.target?.host as string[]).length).toEqual(3); expect((lbRoute.action.targets?.[0]?.host as string[]).length).toEqual(3);
expect((lbRoute.action.target?.host as string[])[0]).toEqual('10.0.0.1'); expect((lbRoute.action.targets?.[0]?.host as string[])[0]).toEqual('10.0.0.1');
expect(lbRoute.action.target?.port).toEqual(8080); expect(lbRoute.action.targets?.[0]?.port).toEqual(8080);
expect(lbRoute.action.tls?.mode).toEqual('terminate'); expect(lbRoute.action.tls?.mode).toEqual('terminate');
}); });
@@ -152,8 +152,8 @@ tap.test('Routes: Should create API route with CORS', async () => {
expect(apiRoute.match.path).toEqual('/v1/*'); expect(apiRoute.match.path).toEqual('/v1/*');
expect(apiRoute.action.type).toEqual('forward'); expect(apiRoute.action.type).toEqual('forward');
expect(apiRoute.action.tls?.mode).toEqual('terminate'); expect(apiRoute.action.tls?.mode).toEqual('terminate');
expect(apiRoute.action.target?.host).toEqual('localhost'); expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(apiRoute.action.target?.port).toEqual(3000); expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
// Check CORS headers // Check CORS headers
expect(apiRoute.headers).toBeDefined(); expect(apiRoute.headers).toBeDefined();
@@ -177,8 +177,8 @@ tap.test('Routes: Should create WebSocket route', async () => {
expect(wsRoute.match.path).toEqual('/socket'); expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward'); expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.tls?.mode).toEqual('terminate'); expect(wsRoute.action.tls?.mode).toEqual('terminate');
expect(wsRoute.action.target?.host).toEqual('localhost'); expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(wsRoute.action.target?.port).toEqual(5000); expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
// Check WebSocket configuration // Check WebSocket configuration
expect(wsRoute.action.websocket).toBeDefined(); expect(wsRoute.action.websocket).toBeDefined();
@@ -209,10 +209,10 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
}) })
], ],
defaults: { defaults: {
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8080 port: 8080
}, }],
security: { security: {
ipAllowList: ['127.0.0.1', '192.168.0.*'], ipAllowList: ['127.0.0.1', '192.168.0.*'],
maxConnections: 100 maxConnections: 100
@@ -294,13 +294,13 @@ tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 }); const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
expect(bestMatch).not.toBeUndefined(); expect(bestMatch).not.toBeUndefined();
if (bestMatch) { if (bestMatch) {
expect(bestMatch.action.target.port).toEqual(3001); // Should match the exact domain route expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route
} }
// Test with a different subdomain - should only match the wildcard route // Test with a different subdomain - should only match the wildcard route
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 }); const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
expect(otherMatches.length).toEqual(1); expect(otherMatches.length).toEqual(1);
expect(otherMatches[0].action.target.port).toEqual(3000); // Should match the wildcard domain route expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route
}); });
tap.test('Edge Case - Disabled Routes', async () => { tap.test('Edge Case - Disabled Routes', async () => {
@@ -316,7 +316,7 @@ tap.test('Edge Case - Disabled Routes', async () => {
// Should only find the enabled route // Should only find the enabled route
expect(matches.length).toEqual(1); expect(matches.length).toEqual(1);
expect(matches[0].action.target.port).toEqual(3000); expect(matches[0].action.targets[0].port).toEqual(3000);
}); });
tap.test('Edge Case - Complex Path and Headers Matching', async () => { tap.test('Edge Case - Complex Path and Headers Matching', async () => {
@@ -333,10 +333,10 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'internal-api', host: 'internal-api',
port: 8080 port: 8080
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto' certificate: 'auto'
@@ -376,10 +376,10 @@ tap.test('Edge Case - Port Range Matching', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'backend', host: 'backend',
port: 3000 port: 3000
} }]
}, },
name: 'Port Range Route' name: 'Port Range Route'
}; };
@@ -404,10 +404,10 @@ tap.test('Edge Case - Port Range Matching', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'backend', host: 'backend',
port: 3000 port: 3000
} }]
}, },
name: 'Multi Range Route' name: 'Multi Range Route'
}; };
@@ -452,7 +452,7 @@ tap.test('Wildcard Domain Handling', async () => {
expect(bestSpecificMatch).not.toBeUndefined(); expect(bestSpecificMatch).not.toBeUndefined();
if (bestSpecificMatch) { if (bestSpecificMatch) {
// Find which route was matched // Find which route was matched
const matchedPort = bestSpecificMatch.action.target.port; const matchedPort = bestSpecificMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`); console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the specific subdomain route (with highest priority) // Verify it's the specific subdomain route (with highest priority)
@@ -465,7 +465,7 @@ tap.test('Wildcard Domain Handling', async () => {
expect(bestWildcardMatch).not.toBeUndefined(); expect(bestWildcardMatch).not.toBeUndefined();
if (bestWildcardMatch) { if (bestWildcardMatch) {
// Find which route was matched // Find which route was matched
const matchedPort = bestWildcardMatch.action.target.port; const matchedPort = bestWildcardMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`); console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the wildcard subdomain route (with medium priority) // Verify it's the wildcard subdomain route (with medium priority)
@@ -513,7 +513,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(webServerMatch).not.toBeUndefined(); expect(webServerMatch).not.toBeUndefined();
if (webServerMatch) { if (webServerMatch) {
expect(webServerMatch.action.type).toEqual('forward'); expect(webServerMatch.action.type).toEqual('forward');
expect(webServerMatch.action.target.host).toEqual('web-server'); expect(webServerMatch.action.targets[0].host).toEqual('web-server');
} }
// Web server (HTTP redirect via socket handler) // Web server (HTTP redirect via socket handler)
@@ -532,7 +532,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(apiMatch).not.toBeUndefined(); expect(apiMatch).not.toBeUndefined();
if (apiMatch) { if (apiMatch) {
expect(apiMatch.action.type).toEqual('forward'); expect(apiMatch.action.type).toEqual('forward');
expect(apiMatch.action.target.host).toEqual('api-server'); expect(apiMatch.action.targets[0].host).toEqual('api-server');
} }
// WebSocket server // WebSocket server
@@ -544,7 +544,7 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(wsMatch).not.toBeUndefined(); expect(wsMatch).not.toBeUndefined();
if (wsMatch) { if (wsMatch) {
expect(wsMatch.action.type).toEqual('forward'); expect(wsMatch.action.type).toEqual('forward');
expect(wsMatch.action.target.host).toEqual('websocket-server'); expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
expect(wsMatch.action.websocket?.enabled).toBeTrue(); expect(wsMatch.action.websocket?.enabled).toBeTrue();
} }

View File

@@ -28,10 +28,10 @@ tap.test('route security should block connections from unauthorized IPs', async
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9990 port: 9990
} }]
}, },
security: { security: {
// Only allow a non-existent IP // Only allow a non-existent IP
@@ -142,10 +142,10 @@ tap.test('route security with block list should work', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9992 port: 9992
} }]
}, },
security: { // Security at route level, not action level security: { // Security at route level, not action level
ipBlockList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'] ipBlockList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
@@ -234,10 +234,10 @@ tap.test('route without security should allow all connections', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9994 port: 9994
} }]
} }
// No security defined // No security defined
}]; }];

View File

@@ -10,10 +10,10 @@ tap.test('route security should be correctly configured', async () => {
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8991 port: 8991
}, }],
security: { security: {
ipAllowList: ['192.168.1.1'], ipAllowList: ['192.168.1.1'],
ipBlockList: ['10.0.0.1'] ipBlockList: ['10.0.0.1']

View File

@@ -26,10 +26,10 @@ tap.test('route-specific security should be enforced', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8877 port: 8877
} }]
}, },
security: { security: {
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'] ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
@@ -108,10 +108,10 @@ tap.test('route-specific IP block list should be enforced', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8879 port: 8879
} }]
}, },
security: { security: {
ipAllowList: ['0.0.0.0/0', '::/0'], // Allow all IPs ipAllowList: ['0.0.0.0/0', '::/0'], // Allow all IPs
@@ -215,10 +215,10 @@ tap.test('routes without security should allow all connections', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 8881 port: 8881
} }]
// No security section - should allow all // No security section - should allow all
} }
}]; }];

View File

@@ -13,10 +13,10 @@ const createRoute = (id: number, domain: string, port: number = 8443) => ({
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 + id port: 3000 + id
}, }],
tls: { tls: {
mode: 'terminate' as const, mode: 'terminate' as const,
certificate: 'auto' as const, certificate: 'auto' as const,
@@ -209,10 +209,10 @@ tap.test('should handle route updates when cert manager is not initialized', asy
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}] }]
}); });

View File

@@ -134,10 +134,10 @@ tap.test('Route Validation - validateRouteAction', async () => {
// Valid forward action // Valid forward action
const validForwardAction: IRouteAction = { const validForwardAction: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
}; };
const validForwardResult = validateRouteAction(validForwardAction); const validForwardResult = validateRouteAction(validForwardAction);
expect(validForwardResult.valid).toBeTrue(); expect(validForwardResult.valid).toBeTrue();
@@ -154,14 +154,14 @@ tap.test('Route Validation - validateRouteAction', async () => {
expect(validSocketResult.valid).toBeTrue(); expect(validSocketResult.valid).toBeTrue();
expect(validSocketResult.errors.length).toEqual(0); expect(validSocketResult.errors.length).toEqual(0);
// Invalid action (missing target) // Invalid action (missing targets)
const invalidAction: IRouteAction = { const invalidAction: IRouteAction = {
type: 'forward' type: 'forward'
}; };
const invalidResult = validateRouteAction(invalidAction); const invalidResult = validateRouteAction(invalidAction);
expect(invalidResult.valid).toBeFalse(); expect(invalidResult.valid).toBeFalse();
expect(invalidResult.errors.length).toBeGreaterThan(0); expect(invalidResult.errors.length).toBeGreaterThan(0);
expect(invalidResult.errors[0]).toInclude('Target is required'); expect(invalidResult.errors[0]).toInclude('Targets array is required');
// Invalid action (missing socket handler) // Invalid action (missing socket handler)
const invalidSocketAction: IRouteAction = { const invalidSocketAction: IRouteAction = {
@@ -180,7 +180,7 @@ tap.test('Route Validation - validateRouteConfig', async () => {
expect(validResult.valid).toBeTrue(); expect(validResult.valid).toBeTrue();
expect(validResult.errors.length).toEqual(0); expect(validResult.errors.length).toEqual(0);
// Invalid route config (missing target) // Invalid route config (missing targets)
const invalidRoute: IRouteConfig = { const invalidRoute: IRouteConfig = {
match: { match: {
domains: 'example.com', domains: 'example.com',
@@ -309,16 +309,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
const actionOverride: Partial<IRouteConfig> = { const actionOverride: Partial<IRouteConfig> = {
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'new-host.local', host: 'new-host.local',
port: 5000 port: 5000
} }]
} }
}; };
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride); const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
expect(actionMergedRoute.action.target.host).toEqual('new-host.local'); expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
expect(actionMergedRoute.action.target.port).toEqual(5000); expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
// Test replacing action with socket handler // Test replacing action with socket handler
const typeChangeOverride: Partial<IRouteConfig> = { const typeChangeOverride: Partial<IRouteConfig> = {
@@ -336,7 +336,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride); const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
expect(typeChangedRoute.action.type).toEqual('socket-handler'); expect(typeChangedRoute.action.type).toEqual('socket-handler');
expect(typeChangedRoute.action.socketHandler).toBeDefined(); expect(typeChangedRoute.action.socketHandler).toBeDefined();
expect(typeChangedRoute.action.target).toBeUndefined(); expect(typeChangedRoute.action.targets).toBeUndefined();
}); });
tap.test('Route Matching - routeMatchesDomain', async () => { tap.test('Route Matching - routeMatchesDomain', async () => {
@@ -379,10 +379,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}; };
@@ -393,10 +393,10 @@ tap.test('Route Matching - routeMatchesPort', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}; };
@@ -427,10 +427,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}; };
@@ -443,10 +443,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}; };
@@ -458,10 +458,10 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}; };
@@ -494,10 +494,10 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
} }]
} }
}; };
@@ -641,7 +641,7 @@ tap.test('Route Utilities - cloneRoute', async () => {
expect(clonedRoute.name).toEqual(originalRoute.name); expect(clonedRoute.name).toEqual(originalRoute.name);
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains); expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
expect(clonedRoute.action.type).toEqual(originalRoute.action.type); expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
expect(clonedRoute.action.target.port).toEqual(originalRoute.action.target.port); expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
// Modify the clone and check that the original is unchanged // Modify the clone and check that the original is unchanged
clonedRoute.name = 'Modified Clone'; clonedRoute.name = 'Modified Clone';
@@ -656,8 +656,8 @@ tap.test('Route Helpers - createHttpRoute', async () => {
expect(route.match.domains).toEqual('example.com'); expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(80); expect(route.match.ports).toEqual(80);
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(route.action.target.host).toEqual('localhost'); expect(route.action.targets?.[0]?.host).toEqual('localhost');
expect(route.action.target.port).toEqual(3000); expect(route.action.targets?.[0]?.port).toEqual(3000);
const validationResult = validateRouteConfig(route); const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue(); expect(validationResult.valid).toBeTrue();
@@ -790,11 +790,11 @@ tap.test('Route Helpers - createLoadBalancerRoute', async () => {
expect(route.match.domains).toEqual('loadbalancer.example.com'); expect(route.match.domains).toEqual('loadbalancer.example.com');
expect(route.match.ports).toEqual(443); expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(Array.isArray(route.action.target.host)).toBeTrue(); expect(route.action.targets).toBeDefined();
if (Array.isArray(route.action.target.host)) { if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
expect(route.action.target.host.length).toEqual(3); expect((route.action.targets[0].host as string[]).length).toEqual(3);
} }
expect(route.action.target.port).toEqual(8080); expect(route.action.targets?.[0]?.port).toEqual(8080);
expect(route.action.tls.mode).toEqual('terminate'); expect(route.action.tls.mode).toEqual('terminate');
const validationResult = validateRouteConfig(route); const validationResult = validateRouteConfig(route);
@@ -819,7 +819,7 @@ tap.test('Route Patterns - createApiGatewayRoute', async () => {
expect(apiGatewayRoute.match.domains).toEqual('api.example.com'); expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
expect(apiGatewayRoute.match.path).toInclude('/v1'); expect(apiGatewayRoute.match.path).toInclude('/v1');
expect(apiGatewayRoute.action.type).toEqual('forward'); expect(apiGatewayRoute.action.type).toEqual('forward');
expect(apiGatewayRoute.action.target.port).toEqual(3000); expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration // Check TLS configuration
if (apiGatewayRoute.action.tls) { if (apiGatewayRoute.action.tls) {
@@ -854,7 +854,7 @@ tap.test('Route Patterns - createWebSocketPattern', async () => {
expect(wsRoute.match.domains).toEqual('ws.example.com'); expect(wsRoute.match.domains).toEqual('ws.example.com');
expect(wsRoute.match.path).toEqual('/socket'); expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward'); expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.target.port).toEqual(3000); expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration // Check TLS configuration
if (wsRoute.action.tls) { if (wsRoute.action.tls) {
@@ -891,8 +891,8 @@ tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
expect(lbRoute.action.type).toEqual('forward'); expect(lbRoute.action.type).toEqual('forward');
// Check target hosts // Check target hosts
if (Array.isArray(lbRoute.action.target.host)) { if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
expect(lbRoute.action.target.host.length).toEqual(3); expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
} }
// Check TLS configuration // Check TLS configuration

View File

@@ -37,10 +37,10 @@ function createRouteConfig(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: destinationIp, host: destinationIp,
port: destinationPort port: destinationPort
} }]
} }
}; };
} }

View File

@@ -0,0 +1,157 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SharedSecurityManager } from '../ts/core/utils/shared-security-manager.js';
import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
let securityManager: SharedSecurityManager;
tap.test('Setup SharedSecurityManager', async () => {
securityManager = new SharedSecurityManager({
maxConnectionsPerIP: 5,
connectionRateLimitPerMinute: 10,
cleanupIntervalMs: 1000 // 1 second for faster testing
});
});
tap.test('IP connection tracking', async () => {
const testIP = '192.168.1.100';
// Track multiple connections
securityManager.trackConnectionByIP(testIP, 'conn1');
securityManager.trackConnectionByIP(testIP, 'conn2');
securityManager.trackConnectionByIP(testIP, 'conn3');
// Verify connection count
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(3);
// Remove a connection
securityManager.removeConnectionByIP(testIP, 'conn2');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(2);
// Remove remaining connections
securityManager.removeConnectionByIP(testIP, 'conn1');
securityManager.removeConnectionByIP(testIP, 'conn3');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(0);
});
tap.test('Per-IP connection limits validation', async () => {
const testIP = '192.168.1.101';
// Track connections up to limit
for (let i = 1; i <= 5; i++) {
// Validate BEFORE tracking the connection (checking if we can add a new connection)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Now track the connection
securityManager.trackConnectionByIP(testIP, `conn${i}`);
}
// Verify we're at the limit
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5);
// Next connection should be rejected (we're already at 5)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Maximum connections per IP');
// Clean up
for (let i = 1; i <= 5; i++) {
securityManager.removeConnectionByIP(testIP, `conn${i}`);
}
});
tap.test('Connection rate limiting', async () => {
const testIP = '192.168.1.102';
// Make connections at the rate limit
// Note: validateIP() already tracks timestamps internally for rate limiting
for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
}
// Next connection should exceed rate limit
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit');
});
tap.test('Route-level connection limits', async () => {
const route: IRouteConfig = {
name: 'test-route',
match: { ports: 443 },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
security: {
maxConnections: 3
}
};
const context: IRouteContext = {
port: 443,
clientIp: '192.168.1.103',
serverIp: '0.0.0.0',
timestamp: Date.now(),
connectionId: 'test-conn',
isTls: true
};
// Test with connection counts below limit
expect(securityManager.isAllowed(route, context, 0)).toBeTrue();
expect(securityManager.isAllowed(route, context, 2)).toBeTrue();
// Test at limit
expect(securityManager.isAllowed(route, context, 3)).toBeFalse();
// Test above limit
expect(securityManager.isAllowed(route, context, 5)).toBeFalse();
});
tap.test('IPv4/IPv6 normalization', async () => {
const ipv4 = '127.0.0.1';
const ipv4Mapped = '::ffff:127.0.0.1';
// Track connection with IPv4
securityManager.trackConnectionByIP(ipv4, 'conn1');
// Both representations should show the same connection
expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(1);
expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(1);
// Track another connection with IPv6 representation
securityManager.trackConnectionByIP(ipv4Mapped, 'conn2');
// Both should show 2 connections
expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(2);
expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(2);
// Clean up
securityManager.removeConnectionByIP(ipv4, 'conn1');
securityManager.removeConnectionByIP(ipv4Mapped, 'conn2');
});
tap.test('Automatic cleanup of expired data', async (tools) => {
const testIP = '192.168.1.104';
// Track a connection and then remove it
securityManager.trackConnectionByIP(testIP, 'temp-conn');
securityManager.removeConnectionByIP(testIP, 'temp-conn');
// Add some rate limit entries (they expire after 1 minute)
for (let i = 0; i < 5; i++) {
securityManager.validateIP(testIP);
}
// Wait for cleanup interval (set to 1 second in our test)
await tools.delayFor(1500);
// The IP should be cleaned up since it has no connections
// Note: We can't directly check the internal map, but we can verify
// that a new connection is allowed (fresh rate limit)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
});
tap.test('Cleanup SharedSecurityManager', async () => {
securityManager.clearIPTracking();
});
tap.start();

View File

@@ -15,10 +15,10 @@ tap.test('should create a SmartCertManager instance', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 3000 port: 3000
}, }],
tls: { tls: {
mode: 'terminate', mode: 'terminate',
certificate: 'auto', certificate: 'auto',

View File

@@ -73,10 +73,10 @@ tap.test('setup port proxy test environment', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: TEST_SERVER_PORT port: TEST_SERVER_PORT
} }]
} }
} }
], ],
@@ -112,10 +112,10 @@ tap.test('should forward TCP connections to custom host', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: TEST_SERVER_PORT port: TEST_SERVER_PORT
} }]
} }
} }
], ],
@@ -157,10 +157,10 @@ tap.test('should forward connections to custom IP', async () => {
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: targetServerPort port: targetServerPort
} }]
} }
} }
], ],
@@ -252,10 +252,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: PROXY_PORT + 5 port: PROXY_PORT + 5
} }]
} }
} }
], ],
@@ -273,10 +273,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: TEST_SERVER_PORT port: TEST_SERVER_PORT
} }]
} }
} }
], ],
@@ -311,10 +311,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: PROXY_PORT + 7 port: PROXY_PORT + 7
} }]
} }
} }
], ],
@@ -334,10 +334,10 @@ tap.test('should support optional source IP preservation in chained proxies', as
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: TEST_SERVER_PORT port: TEST_SERVER_PORT
} }]
} }
} }
], ],
@@ -377,10 +377,10 @@ tap.test('should use round robin for multiple target hosts in domain config', as
}, },
action: { action: {
type: 'forward' as const, type: 'forward' as const,
target: { targets: [{
host: ['hostA', 'hostB'], // Array of hosts for round-robin host: ['hostA', 'hostB'], // Array of hosts for round-robin
port: 80 port: 80
} }]
} }
}; };
@@ -400,9 +400,9 @@ tap.test('should use round robin for multiple target hosts in domain config', as
// For route-based approach, the actual round-robin logic happens in connection handling // For route-based approach, the actual round-robin logic happens in connection handling
// Just make sure our config has the expected hosts // Just make sure our config has the expected hosts
expect(Array.isArray(routeConfig.action.target.host)).toBeTrue(); expect(Array.isArray(routeConfig.action.targets![0].host)).toBeTrue();
expect(routeConfig.action.target.host).toContain('hostA'); expect(routeConfig.action.targets![0].host).toContain('hostA');
expect(routeConfig.action.target.host).toContain('hostB'); expect(routeConfig.action.targets![0].host).toContain('hostB');
}); });
// CLEANUP: Tear down all servers and proxies // CLEANUP: Tear down all servers and proxies

View File

@@ -30,7 +30,7 @@ tap.test('stuck connection cleanup - verify connections to hanging backends are
match: { ports: 8589 }, match: { ports: 8589 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9997 } targets: [{ host: 'localhost', port: 9997 }]
} }
}], }],
keepAlive: true, keepAlive: true,

View File

@@ -17,7 +17,7 @@ tap.test('websocket keep-alive settings for SNI passthrough', async (tools) => {
match: { ports: 8443, domains: 'test.local' }, match: { ports: 8443, domains: 'test.local' },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9443 }, targets: [{ host: 'localhost', port: 9443 }],
tls: { mode: 'passthrough' } tls: { mode: 'passthrough' }
} }
} }
@@ -108,7 +108,7 @@ tap.test('long-lived connection survival test', async (tools) => {
match: { ports: 8444 }, match: { ports: 8444 },
action: { action: {
type: 'forward', type: 'forward',
target: { host: 'localhost', port: 9444 } targets: [{ host: 'localhost', port: 9444 }]
} }
} }
] ]

View File

@@ -52,10 +52,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
match: { ports: 8591 }, match: { ports: 8591 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 9998 port: 9998
} }]
} }
}] }]
}); });
@@ -71,10 +71,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
match: { ports: 8590 }, match: { ports: 8590 },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: 'localhost', host: 'localhost',
port: 8591 port: 8591
} }]
} }
}] }]
}); });

View File

@@ -0,0 +1,370 @@
import { logger } from './logger.js';
interface ILogEvent {
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: any;
count: number;
firstSeen: number;
lastSeen: number;
}
interface IAggregatedEvent {
key: string;
events: Map<string, ILogEvent>;
flushTimer?: NodeJS.Timeout;
}
/**
* Log deduplication utility to reduce log spam for repetitive events
*/
export class LogDeduplicator {
private globalFlushTimer?: NodeJS.Timeout;
private aggregatedEvents: Map<string, IAggregatedEvent> = new Map();
private flushInterval: number = 5000; // 5 seconds
private maxBatchSize: number = 100;
private rapidEventThreshold: number = 50; // Flush early if this many events in 1 second
private lastRapidCheck: number = Date.now();
constructor(flushInterval?: number) {
if (flushInterval) {
this.flushInterval = flushInterval;
}
// Set up global periodic flush to ensure logs are emitted regularly
this.globalFlushTimer = setInterval(() => {
this.flushAll();
}, this.flushInterval * 2); // Flush everything every 2x the normal interval
if (this.globalFlushTimer.unref) {
this.globalFlushTimer.unref();
}
}
/**
* Log a deduplicated event
* @param key - Aggregation key (e.g., 'connection-rejected', 'cleanup-batch')
* @param level - Log level
* @param message - Log message template
* @param data - Additional data
* @param dedupeKey - Deduplication key within the aggregation (e.g., IP address, reason)
*/
public log(
key: string,
level: 'info' | 'warn' | 'error' | 'debug',
message: string,
data?: any,
dedupeKey?: string
): void {
const eventKey = dedupeKey || message;
const now = Date.now();
if (!this.aggregatedEvents.has(key)) {
this.aggregatedEvents.set(key, {
key,
events: new Map(),
flushTimer: undefined
});
}
const aggregated = this.aggregatedEvents.get(key)!;
if (aggregated.events.has(eventKey)) {
const event = aggregated.events.get(eventKey)!;
event.count++;
event.lastSeen = now;
if (data) {
event.data = { ...event.data, ...data };
}
} else {
aggregated.events.set(eventKey, {
level,
message,
data,
count: 1,
firstSeen: now,
lastSeen: now
});
}
// Check for rapid events (many events in short time)
const totalEvents = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
// If we're getting flooded with events, flush more frequently
if (now - this.lastRapidCheck < 1000 && totalEvents >= this.rapidEventThreshold) {
this.flush(key);
this.lastRapidCheck = now;
} else if (aggregated.events.size >= this.maxBatchSize) {
// Check if we should flush due to size
this.flush(key);
} else if (!aggregated.flushTimer) {
// Schedule flush
aggregated.flushTimer = setTimeout(() => {
this.flush(key);
}, this.flushInterval);
if (aggregated.flushTimer.unref) {
aggregated.flushTimer.unref();
}
}
// Update rapid check time
if (now - this.lastRapidCheck >= 1000) {
this.lastRapidCheck = now;
}
}
/**
* Flush aggregated events for a specific key
*/
public flush(key: string): void {
const aggregated = this.aggregatedEvents.get(key);
if (!aggregated || aggregated.events.size === 0) {
return;
}
if (aggregated.flushTimer) {
clearTimeout(aggregated.flushTimer);
aggregated.flushTimer = undefined;
}
// Emit aggregated log based on the key
switch (key) {
case 'connection-rejected':
this.flushConnectionRejections(aggregated);
break;
case 'connection-cleanup':
this.flushConnectionCleanups(aggregated);
break;
case 'connection-terminated':
this.flushConnectionTerminations(aggregated);
break;
case 'ip-rejected':
this.flushIPRejections(aggregated);
break;
default:
this.flushGeneric(aggregated);
}
// Clear events
aggregated.events.clear();
}
/**
* Flush all pending events
*/
public flushAll(): void {
for (const key of this.aggregatedEvents.keys()) {
this.flush(key);
}
}
private flushConnectionRejections(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'unknown';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
logger.log('warn', `[SUMMARY] Rejected ${totalCount} connections in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
uniqueIPs: aggregated.events.size,
component: 'connection-dedup'
});
}
private flushConnectionCleanups(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'normal';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5) // Top 5 reasons
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
logger.log('info', `Cleaned up ${totalCount} connections`, {
reasons: reasonSummary,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'connection-dedup'
});
}
private flushConnectionTerminations(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const byReason = new Map<string, number>();
const byIP = new Map<string, number>();
let lastActiveCount = 0;
for (const [, event] of aggregated.events) {
const reason = event.data?.reason || 'unknown';
const ip = event.data?.remoteIP || 'unknown';
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
// Track by IP
if (ip !== 'unknown') {
byIP.set(ip, (byIP.get(ip) || 0) + event.count);
}
// Track the last active connection count
if (event.data?.activeConnections !== undefined) {
lastActiveCount = event.data.activeConnections;
}
}
const reasonSummary = Array.from(byReason.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5) // Top 5 reasons
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
// Show top IPs if there are many different ones
let ipInfo = '';
if (byIP.size > 3) {
const topIPs = Array.from(byIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([ip, count]) => `${ip} (${count})`)
.join(', ');
ipInfo = `, from ${byIP.size} IPs (top: ${topIPs})`;
} else if (byIP.size > 0) {
ipInfo = `, IPs: ${Array.from(byIP.keys()).join(', ')}`;
}
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
// Special handling for localhost connections (HttpProxy)
const localhostCount = byIP.get('::ffff:127.0.0.1') || 0;
if (localhostCount > 0 && byIP.size === 1) {
// All connections are from localhost (HttpProxy)
logger.log('info', `[SUMMARY] ${totalCount} HttpProxy connections terminated in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
activeConnections: lastActiveCount,
component: 'connection-dedup'
});
} else {
logger.log('info', `[SUMMARY] ${totalCount} connections terminated in ${Math.round(duration/1000)}s`, {
reasons: reasonSummary,
activeConnections: lastActiveCount,
uniqueReasons: byReason.size,
...(ipInfo ? { ips: ipInfo } : {}),
component: 'connection-dedup'
});
}
}
private flushIPRejections(aggregated: IAggregatedEvent): void {
const byIP = new Map<string, { count: number; reasons: Set<string> }>();
const allReasons = new Map<string, number>();
for (const [ip, event] of aggregated.events) {
if (!byIP.has(ip)) {
byIP.set(ip, { count: 0, reasons: new Set() });
}
const ipData = byIP.get(ip)!;
ipData.count += event.count;
if (event.data?.reason) {
ipData.reasons.add(event.data.reason);
// Track overall reason counts
allReasons.set(event.data.reason, (allReasons.get(event.data.reason) || 0) + event.count);
}
}
// Create reason summary
const reasonSummary = Array.from(allReasons.entries())
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${reason}: ${count}`)
.join(', ');
// Log top offenders
const topOffenders = Array.from(byIP.entries())
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 10)
.map(([ip, data]) => `${ip} (${data.count}x, ${Array.from(data.reasons).join('/')})`)
.join(', ');
const totalRejections = Array.from(byIP.values()).reduce((sum, data) => sum + data.count, 0);
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
logger.log('warn', `[SUMMARY] Rejected ${totalRejections} connections from ${byIP.size} IPs in ${Math.round(duration/1000)}s (${reasonSummary})`, {
topOffenders,
component: 'ip-dedup'
});
}
private flushGeneric(aggregated: IAggregatedEvent): void {
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
const level = aggregated.events.values().next().value?.level || 'info';
// Special handling for IP cleanup events
if (aggregated.key === 'ip-cleanup') {
const totalCleaned = Array.from(aggregated.events.values()).reduce((sum, e) => {
return sum + (e.data?.cleanedIPs || 0) + (e.data?.cleanedRateLimits || 0);
}, 0);
if (totalCleaned > 0) {
logger.log(level as any, `IP tracking cleanup: removed ${totalCleaned} entries across ${totalCount} cleanup cycles`, {
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'log-dedup'
});
}
} else {
logger.log(level as any, `${aggregated.key}: ${totalCount} events`, {
uniqueEvents: aggregated.events.size,
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
component: 'log-dedup'
});
}
}
/**
* Cleanup and stop deduplication
*/
public cleanup(): void {
this.flushAll();
if (this.globalFlushTimer) {
clearInterval(this.globalFlushTimer);
this.globalFlushTimer = undefined;
}
for (const aggregated of this.aggregatedEvents.values()) {
if (aggregated.flushTimer) {
clearTimeout(aggregated.flushTimer);
}
}
this.aggregatedEvents.clear();
}
}
// Global instance for connection-related log deduplication
export const connectionLogDeduplicator = new LogDeduplicator(5000); // 5 second batches
// Ensure logs are flushed on process exit
process.on('beforeExit', () => {
connectionLogDeduplicator.flushAll();
});
process.on('SIGINT', () => {
connectionLogDeduplicator.cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
connectionLogDeduplicator.cleanup();
process.exit(0);
});

View File

@@ -13,7 +13,8 @@ import {
trackConnection, trackConnection,
removeConnection, removeConnection,
cleanupExpiredRateLimits, cleanupExpiredRateLimits,
parseBasicAuthHeader parseBasicAuthHeader,
normalizeIP
} from './security-utils.js'; } from './security-utils.js';
/** /**
@@ -78,7 +79,15 @@ export class SharedSecurityManager {
* @returns Number of connections from this IP * @returns Number of connections from this IP
*/ */
public getConnectionCountByIP(ip: string): number { public getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.connections.size || 0; // Check all normalized variants of the IP
const variants = normalizeIP(ip);
for (const variant of variants) {
const info = this.connectionsByIP.get(variant);
if (info) {
return info.connections.size;
}
}
return 0;
} }
/** /**
@@ -88,7 +97,19 @@ export class SharedSecurityManager {
* @param connectionId - The connection ID to associate * @param connectionId - The connection ID to associate
*/ */
public trackConnectionByIP(ip: string, connectionId: string): void { public trackConnectionByIP(ip: string, connectionId: string): void {
trackConnection(ip, connectionId, this.connectionsByIP); // Check if any variant already exists
const variants = normalizeIP(ip);
let existingKey: string | null = null;
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
existingKey = variant;
break;
}
}
// Use existing key or the original IP
trackConnection(existingKey || ip, connectionId, this.connectionsByIP);
} }
/** /**
@@ -98,7 +119,15 @@ export class SharedSecurityManager {
* @param connectionId - The connection ID to remove * @param connectionId - The connection ID to remove
*/ */
public removeConnectionByIP(ip: string, connectionId: string): void { public removeConnectionByIP(ip: string, connectionId: string): void {
removeConnection(ip, connectionId, this.connectionsByIP); // Check all variants to find where the connection is tracked
const variants = normalizeIP(ip);
for (const variant of variants) {
if (this.connectionsByIP.has(variant)) {
removeConnection(variant, connectionId, this.connectionsByIP);
break;
}
}
} }
/** /**
@@ -152,9 +181,10 @@ export class SharedSecurityManager {
* *
* @param route - The route to check * @param route - The route to check
* @param context - The request context * @param context - The request context
* @param routeConnectionCount - Current connection count for this route (optional)
* @returns Whether access is allowed * @returns Whether access is allowed
*/ */
public isAllowed(route: IRouteConfig, context: IRouteContext): boolean { public isAllowed(route: IRouteConfig, context: IRouteContext, routeConnectionCount?: number): boolean {
if (!route.security) { if (!route.security) {
return true; // No security restrictions return true; // No security restrictions
} }
@@ -165,6 +195,14 @@ export class SharedSecurityManager {
return false; return false;
} }
// --- Route-level connection limit ---
if (route.security.maxConnections !== undefined && routeConnectionCount !== undefined) {
if (routeConnectionCount >= route.security.maxConnections) {
this.logger?.debug?.(`Route connection limit (${route.security.maxConnections}) exceeded for route ${route.name || 'unnamed'}`);
return false;
}
}
// --- Rate limiting --- // --- Rate limiting ---
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) { if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`); this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
@@ -304,6 +342,20 @@ export class SharedSecurityManager {
// Clean up rate limits // Clean up rate limits
cleanupExpiredRateLimits(this.rateLimits, this.logger); cleanupExpiredRateLimits(this.rateLimits, this.logger);
// Clean up IP connection tracking
let cleanedIPs = 0;
for (const [ip, info] of this.connectionsByIP.entries()) {
// Remove IPs with no active connections and no recent timestamps
if (info.connections.size === 0 && info.timestamps.length === 0) {
this.connectionsByIP.delete(ip);
cleanedIPs++;
}
}
if (cleanedIPs > 0 && this.logger?.debug) {
this.logger.debug(`Cleaned up ${cleanedIPs} IPs with no active connections`);
}
// IP filter cache doesn't need cleanup (tied to routes) // IP filter cache doesn't need cleanup (tied to routes)
} }

View File

@@ -17,6 +17,8 @@ import { WebSocketHandler } from './websocket-handler.js';
import { HttpRouter } from '../../routing/router/index.js'; import { HttpRouter } from '../../routing/router/index.js';
import { cleanupSocket } from '../../core/utils/socket-utils.js'; import { cleanupSocket } from '../../core/utils/socket-utils.js';
import { FunctionCache } from './function-cache.js'; import { FunctionCache } from './function-cache.js';
import { SecurityManager } from './security-manager.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
/** /**
* HttpProxy provides a reverse proxy with TLS termination, WebSocket support, * HttpProxy provides a reverse proxy with TLS termination, WebSocket support,
@@ -43,6 +45,7 @@ export class HttpProxy implements IMetricsTracker {
private router = new HttpRouter(); // Unified HTTP router private router = new HttpRouter(); // Unified HTTP router
private routeManager: RouteManager; private routeManager: RouteManager;
private functionCache: FunctionCache; private functionCache: FunctionCache;
private securityManager: SecurityManager;
// State tracking // State tracking
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>(); public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
@@ -114,6 +117,14 @@ export class HttpProxy implements IMetricsTracker {
defaultTtl: this.options.functionCacheTtl || 5000 defaultTtl: this.options.functionCacheTtl || 5000
}); });
// Initialize security manager
this.securityManager = new SecurityManager(
this.logger,
[],
this.options.maxConnectionsPerIP || 100,
this.options.connectionRateLimitPerMinute || 300
);
// Initialize other components // Initialize other components
this.certificateManager = new CertificateManager(this.options); this.certificateManager = new CertificateManager(this.options);
this.connectionPool = new ConnectionPool(this.options); this.connectionPool = new ConnectionPool(this.options);
@@ -269,14 +280,113 @@ export class HttpProxy implements IMetricsTracker {
*/ */
private setupConnectionTracking(): void { private setupConnectionTracking(): void {
this.httpsServer.on('connection', (connection: plugins.net.Socket) => { this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
// Check if max connections reached let remoteIP = connection.remoteAddress || '';
const connectionId = Math.random().toString(36).substring(2, 15);
const isFromSmartProxy = this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1');
// For SmartProxy connections, wait for CLIENT_IP header
if (isFromSmartProxy) {
let headerBuffer = Buffer.alloc(0);
let headerParsed = false;
const parseHeader = (data: Buffer) => {
if (headerParsed) return data;
headerBuffer = Buffer.concat([headerBuffer, data]);
const headerStr = headerBuffer.toString();
const headerEnd = headerStr.indexOf('\r\n');
if (headerEnd !== -1) {
const header = headerStr.substring(0, headerEnd);
if (header.startsWith('CLIENT_IP:')) {
remoteIP = header.substring(10); // Extract IP after "CLIENT_IP:"
this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`);
}
headerParsed = true;
// Store the real IP on the connection
(connection as any)._realRemoteIP = remoteIP;
// Validate the real IP
const ipValidation = this.securityManager.validateIP(remoteIP);
if (!ipValidation.allowed) {
connectionLogDeduplicator.log(
'ip-rejected',
'warn',
`HttpProxy connection rejected (via SmartProxy)`,
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
remoteIP
);
connection.destroy();
return null;
}
// Track connection by real IP
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
// Return remaining data after header
return headerBuffer.slice(headerEnd + 2);
}
return null;
};
// Override the first data handler to parse header
const originalEmit = connection.emit;
connection.emit = function(event: string, ...args: any[]) {
if (event === 'data' && !headerParsed) {
const remaining = parseHeader(args[0]);
if (remaining && remaining.length > 0) {
// Call original emit with remaining data
return originalEmit.apply(connection, ['data', remaining]);
} else if (headerParsed) {
// Header parsed but no remaining data
return true;
}
// Header not complete yet, suppress this data event
return true;
}
return originalEmit.apply(connection, [event, ...args]);
} as any;
} else {
// Direct connection - validate immediately
const ipValidation = this.securityManager.validateIP(remoteIP);
if (!ipValidation.allowed) {
connectionLogDeduplicator.log(
'ip-rejected',
'warn',
`HttpProxy connection rejected`,
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
remoteIP
);
connection.destroy();
return;
}
// Track connection by IP
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
}
// Then check global max connections
if (this.socketMap.getArray().length >= this.options.maxConnections) { if (this.socketMap.getArray().length >= this.options.maxConnections) {
this.logger.warn(`Max connections (${this.options.maxConnections}) reached, rejecting new connection`); connectionLogDeduplicator.log(
'connection-rejected',
'warn',
'HttpProxy max connections reached',
{
reason: 'global-limit',
currentConnections: this.socketMap.getArray().length,
maxConnections: this.options.maxConnections,
component: 'http-proxy'
},
'http-proxy-global-limit'
);
connection.destroy(); connection.destroy();
return; return;
} }
// Add connection to tracking // Add connection to tracking with metadata
(connection as any)._connectionId = connectionId;
(connection as any)._remoteIP = remoteIP;
this.socketMap.add(connection); this.socketMap.add(connection);
this.connectedClients = this.socketMap.getArray().length; this.connectedClients = this.socketMap.getArray().length;
@@ -284,12 +394,12 @@ export class HttpProxy implements IMetricsTracker {
const localPort = connection.localPort || 0; const localPort = connection.localPort || 0;
const remotePort = connection.remotePort || 0; const remotePort = connection.remotePort || 0;
// If this connection is from a SmartProxy (usually indicated by it coming from localhost) // If this connection is from a SmartProxy
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) { if (isFromSmartProxy) {
this.portProxyConnections++; this.portProxyConnections++;
this.logger.debug(`New connection from SmartProxy (local: ${localPort}, remote: ${remotePort})`); this.logger.debug(`New connection from SmartProxy for client ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
} else { } else {
this.logger.debug(`New direct connection (local: ${localPort}, remote: ${remotePort})`); this.logger.debug(`New direct connection from ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
} }
// Setup connection cleanup handlers // Setup connection cleanup handlers
@@ -298,12 +408,19 @@ export class HttpProxy implements IMetricsTracker {
this.socketMap.remove(connection); this.socketMap.remove(connection);
this.connectedClients = this.socketMap.getArray().length; this.connectedClients = this.socketMap.getArray().length;
// Remove IP tracking
const connId = (connection as any)._connectionId;
const connIP = (connection as any)._realRemoteIP || (connection as any)._remoteIP;
if (connId && connIP) {
this.securityManager.removeConnectionByIP(connIP, connId);
}
// If this was a SmartProxy connection, decrement the counter // If this was a SmartProxy connection, decrement the counter
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) { if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections--; this.portProxyConnections--;
} }
this.logger.debug(`Connection closed. ${this.connectedClients} connections remaining`); this.logger.debug(`Connection closed from ${connIP || 'unknown'}. ${this.connectedClients} connections remaining`);
} }
}; };
@@ -480,6 +597,9 @@ export class HttpProxy implements IMetricsTracker {
// Certificate management cleanup is handled by SmartCertManager // Certificate management cleanup is handled by SmartCertManager
// Flush any pending deduplicated logs
connectionLogDeduplicator.flushAll();
// Close the HTTPS server // Close the HTTPS server
return new Promise((resolve) => { return new Promise((resolve) => {
this.httpsServer.close(() => { this.httpsServer.close(() => {

View File

@@ -45,6 +45,10 @@ export interface IHttpProxyOptions {
// Direct route configurations // Direct route configurations
routes?: IRouteConfig[]; routes?: IRouteConfig[];
// Rate limiting and security
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
} }
/** /**

View File

@@ -10,7 +10,7 @@ import { ConnectionPool } from './connection-pool.js';
import { ContextCreator } from './context-creator.js'; import { ContextCreator } from './context-creator.js';
import { HttpRequestHandler } from './http-request-handler.js'; import { HttpRequestHandler } from './http-request-handler.js';
import { Http2RequestHandler } from './http2-request-handler.js'; import { Http2RequestHandler } from './http2-request-handler.js';
import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js'; import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
import { toBaseContext } from '../../core/models/route-context.js'; import { toBaseContext } from '../../core/models/route-context.js';
import { TemplateUtils } from '../../core/utils/template-utils.js'; import { TemplateUtils } from '../../core/utils/template-utils.js';
@@ -99,6 +99,80 @@ export class RequestHandler {
return { ...this.defaultHeaders }; return { ...this.defaultHeaders };
} }
/**
* Select the appropriate target from the targets array based on sub-matching criteria
*/
private selectTarget(
targets: IRouteTarget[],
context: {
port: number;
path?: string;
headers?: Record<string, string>;
method?: string;
}
): IRouteTarget | null {
// Sort targets by priority (higher first)
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Find the first matching target
for (const target of sortedTargets) {
if (!target.match) {
// No match criteria means this is a default/fallback target
return target;
}
// Check port match
if (target.match.ports && !target.match.ports.includes(context.port)) {
continue;
}
// Check path match (supports wildcards)
if (target.match.path && context.path) {
const pathPattern = target.match.path.replace(/\*/g, '.*');
const pathRegex = new RegExp(`^${pathPattern}$`);
if (!pathRegex.test(context.path)) {
continue;
}
}
// Check method match
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
continue;
}
// Check headers match
if (target.match.headers && context.headers) {
let headersMatch = true;
for (const [key, pattern] of Object.entries(target.match.headers)) {
const headerValue = context.headers[key.toLowerCase()];
if (!headerValue) {
headersMatch = false;
break;
}
if (pattern instanceof RegExp) {
if (!pattern.test(headerValue)) {
headersMatch = false;
break;
}
} else if (headerValue !== pattern) {
headersMatch = false;
break;
}
}
if (!headersMatch) {
continue;
}
}
// All criteria matched
return target;
}
// No matching target found, return the first target without match criteria (default)
return sortedTargets.find(t => !t.match) || null;
}
/** /**
* Apply CORS headers to response if configured * Apply CORS headers to response if configured
* Implements Phase 5.5: Context-aware CORS handling * Implements Phase 5.5: Context-aware CORS handling
@@ -480,17 +554,31 @@ export class RequestHandler {
} }
} }
// If we found a matching route with function-based targets, use it // If we found a matching route with forward action, select appropriate target
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`); this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`);
// Select the appropriate target from the targets array
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
port: routeContext.port,
path: routeContext.path,
headers: routeContext.headers,
method: routeContext.method
});
if (!selectedTarget) {
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
req.socket.end();
return;
}
// Extract target information, resolving functions if needed // Extract target information, resolving functions if needed
let targetHost: string | string[]; let targetHost: string | string[];
let targetPort: number; let targetPort: number;
try { try {
// Check function cache for host and resolve or use cached value // Check function cache for host and resolve or use cached value
if (typeof matchingRoute.action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
// Generate a function ID for caching (use route name or ID if available) // Generate a function ID for caching (use route name or ID if available)
const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -502,7 +590,7 @@ export class RequestHandler {
this.logger.debug(`Using cached host value for ${functionId}`); this.logger.debug(`Using cached host value for ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
targetHost = resolvedHost; targetHost = resolvedHost;
// Cache the result // Cache the result
@@ -511,16 +599,16 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedHost = matchingRoute.action.target.host(routeContext); const resolvedHost = selectedTarget.host(routeContext);
targetHost = resolvedHost; targetHost = resolvedHost;
this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
} }
} else { } else {
targetHost = matchingRoute.action.target.host; targetHost = selectedTarget.host;
} }
// Check function cache for port and resolve or use cached value // Check function cache for port and resolve or use cached value
if (typeof matchingRoute.action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
// Generate a function ID for caching // Generate a function ID for caching
const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -532,7 +620,7 @@ export class RequestHandler {
this.logger.debug(`Using cached port value for ${functionId}`); this.logger.debug(`Using cached port value for ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
targetPort = resolvedPort; targetPort = resolvedPort;
// Cache the result // Cache the result
@@ -541,12 +629,12 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedPort = matchingRoute.action.target.port(routeContext); const resolvedPort = selectedTarget.port(routeContext);
targetPort = resolvedPort; targetPort = resolvedPort;
this.logger.debug(`Resolved function-based port to: ${resolvedPort}`); this.logger.debug(`Resolved function-based port to: ${resolvedPort}`);
} }
} else { } else {
targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
} }
// Select a single host if an array was provided // Select a single host if an array was provided
@@ -626,17 +714,32 @@ export class RequestHandler {
} }
} }
// If we found a matching route with function-based targets, use it // If we found a matching route with forward action, select appropriate target
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`); this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`);
// Select the appropriate target from the targets array
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
port: routeContext.port,
path: routeContext.path,
headers: routeContext.headers,
method: routeContext.method
});
if (!selectedTarget) {
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
stream.respond({ ':status': 502 });
stream.end();
return;
}
// Extract target information, resolving functions if needed // Extract target information, resolving functions if needed
let targetHost: string | string[]; let targetHost: string | string[];
let targetPort: number; let targetPort: number;
try { try {
// Check function cache for host and resolve or use cached value // Check function cache for host and resolve or use cached value
if (typeof matchingRoute.action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
// Generate a function ID for caching (use route name or ID if available) // Generate a function ID for caching (use route name or ID if available)
const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -648,7 +751,7 @@ export class RequestHandler {
this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`); this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
targetHost = resolvedHost; targetHost = resolvedHost;
// Cache the result // Cache the result
@@ -657,16 +760,16 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedHost = matchingRoute.action.target.host(routeContext); const resolvedHost = selectedTarget.host(routeContext);
targetHost = resolvedHost; targetHost = resolvedHost;
this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
} }
} else { } else {
targetHost = matchingRoute.action.target.host; targetHost = selectedTarget.host;
} }
// Check function cache for port and resolve or use cached value // Check function cache for port and resolve or use cached value
if (typeof matchingRoute.action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
// Generate a function ID for caching // Generate a function ID for caching
const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
@@ -678,7 +781,7 @@ export class RequestHandler {
this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`); this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`);
} else { } else {
// Resolve the function and cache the result // Resolve the function and cache the result
const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
targetPort = resolvedPort; targetPort = resolvedPort;
// Cache the result // Cache the result
@@ -687,12 +790,12 @@ export class RequestHandler {
} }
} else { } else {
// No cache available, just resolve // No cache available, just resolve
const resolvedPort = matchingRoute.action.target.port(routeContext); const resolvedPort = selectedTarget.port(routeContext);
targetPort = resolvedPort; targetPort = resolvedPort;
this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`); this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`);
} }
} else { } else {
targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
} }
// Select a single host if an array was provided // Select a single host if an array was provided

View File

@@ -14,7 +14,14 @@ export class SecurityManager {
// Store rate limits per route and key // Store rate limits per route and key
private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = new Map(); private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = new Map();
constructor(private logger: ILogger, private routes: IRouteConfig[] = []) {} // Connection tracking by IP
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
constructor(private logger: ILogger, private routes: IRouteConfig[] = [], private maxConnectionsPerIP: number = 100, private connectionRateLimitPerMinute: number = 300) {
// Start periodic cleanup for connection tracking
this.startPeriodicIpCleanup();
}
/** /**
* Update the routes configuration * Update the routes configuration
@@ -295,4 +302,132 @@ export class SecurityManager {
return false; return false;
} }
} }
/**
* Get connections count by IP
*/
public getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
* @returns true if within rate limit, false if exceeding limit
*/
public checkConnectionRate(ip: string): boolean {
const now = Date.now();
const minute = 60 * 1000;
if (!this.connectionRateByIP.has(ip)) {
this.connectionRateByIP.set(ip, [now]);
return true;
}
// Get timestamps and filter out entries older than 1 minute
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
timestamps.push(now);
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.connectionRateLimitPerMinute;
}
/**
* Track connection by IP
*/
public trackConnectionByIP(ip: string, connectionId: string): void {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip)!.add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
public removeConnectionByIP(ip: string, connectionId: string): void {
if (this.connectionsByIP.has(ip)) {
const connections = this.connectionsByIP.get(ip)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
}
}
}
/**
* Check if IP should be allowed considering connection rate and max connections
* @returns Object with result and reason
*/
public validateIP(ip: string): { allowed: boolean; reason?: string } {
// Check connection count limit
if (this.getConnectionCountByIP(ip) >= this.maxConnectionsPerIP) {
return {
allowed: false,
reason: `Maximum connections per IP (${this.maxConnectionsPerIP}) exceeded`
};
}
// Check connection rate limit
if (!this.checkConnectionRate(ip)) {
return {
allowed: false,
reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded`
};
}
return { allowed: true };
}
/**
* Clears all IP tracking data (for shutdown)
*/
public clearIPTracking(): void {
this.connectionsByIP.clear();
this.connectionRateByIP.clear();
}
/**
* Start periodic cleanup of IP tracking data
*/
private startPeriodicIpCleanup(): void {
// Clean up IP tracking data every minute
setInterval(() => {
this.performIpCleanup();
}, 60000).unref();
}
/**
* Perform cleanup of expired IP data
*/
private performIpCleanup(): void {
const now = Date.now();
const minute = 60 * 1000;
let cleanedRateLimits = 0;
let cleanedIPs = 0;
// Clean up expired rate limit timestamps
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
const validTimestamps = timestamps.filter(time => now - time < minute);
if (validTimestamps.length === 0) {
this.connectionRateByIP.delete(ip);
cleanedRateLimits++;
} else if (validTimestamps.length < timestamps.length) {
this.connectionRateByIP.set(ip, validTimestamps);
}
}
// Clean up IPs with no active connections
for (const [ip, connections] of this.connectionsByIP.entries()) {
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
cleanedIPs++;
}
}
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`);
}
}
} }

View File

@@ -3,7 +3,7 @@ import '../../core/models/socket-augmentation.js';
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js'; import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js';
import { ConnectionPool } from './connection-pool.js'; import { ConnectionPool } from './connection-pool.js';
import { HttpRouter } from '../../routing/router/index.js'; import { HttpRouter } from '../../routing/router/index.js';
import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
import type { IRouteContext } from '../../core/models/route-context.js'; import type { IRouteContext } from '../../core/models/route-context.js';
import { toBaseContext } from '../../core/models/route-context.js'; import { toBaseContext } from '../../core/models/route-context.js';
import { ContextCreator } from './context-creator.js'; import { ContextCreator } from './context-creator.js';
@@ -53,6 +53,80 @@ export class WebSocketHandler {
this.securityManager.setRoutes(routes); this.securityManager.setRoutes(routes);
} }
/**
* Select the appropriate target from the targets array based on sub-matching criteria
*/
private selectTarget(
targets: IRouteTarget[],
context: {
port: number;
path?: string;
headers?: Record<string, string>;
method?: string;
}
): IRouteTarget | null {
// Sort targets by priority (higher first)
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Find the first matching target
for (const target of sortedTargets) {
if (!target.match) {
// No match criteria means this is a default/fallback target
return target;
}
// Check port match
if (target.match.ports && !target.match.ports.includes(context.port)) {
continue;
}
// Check path match (supports wildcards)
if (target.match.path && context.path) {
const pathPattern = target.match.path.replace(/\*/g, '.*');
const pathRegex = new RegExp(`^${pathPattern}$`);
if (!pathRegex.test(context.path)) {
continue;
}
}
// Check method match
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
continue;
}
// Check headers match
if (target.match.headers && context.headers) {
let headersMatch = true;
for (const [key, pattern] of Object.entries(target.match.headers)) {
const headerValue = context.headers[key.toLowerCase()];
if (!headerValue) {
headersMatch = false;
break;
}
if (pattern instanceof RegExp) {
if (!pattern.test(headerValue)) {
headersMatch = false;
break;
}
} else if (headerValue !== pattern) {
headersMatch = false;
break;
}
}
if (!headersMatch) {
continue;
}
}
// All criteria matched
return target;
}
// No matching target found, return the first target without match criteria (default)
return sortedTargets.find(t => !t.match) || null;
}
/** /**
* Initialize WebSocket server on an existing HTTPS server * Initialize WebSocket server on an existing HTTPS server
*/ */
@@ -146,9 +220,23 @@ export class WebSocketHandler {
let destination: { host: string; port: number }; let destination: { host: string; port: number };
// If we found a route with the modern router, use it // If we found a route with the modern router, use it
if (route && route.action.type === 'forward' && route.action.target) { if (route && route.action.type === 'forward' && route.action.targets && route.action.targets.length > 0) {
this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`); this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`);
// Select the appropriate target from the targets array
const selectedTarget = this.selectTarget(route.action.targets, {
port: routeContext.port,
path: routeContext.path,
headers: routeContext.headers,
method: routeContext.method
});
if (!selectedTarget) {
this.logger.error(`No matching target found for route ${route.name}`);
wsIncoming.close(1003, 'No matching target');
return;
}
// Check if WebSockets are enabled for this route // Check if WebSockets are enabled for this route
if (route.action.websocket?.enabled === false) { if (route.action.websocket?.enabled === false) {
this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`); this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`);
@@ -192,20 +280,20 @@ export class WebSocketHandler {
try { try {
// Resolve host if it's a function // Resolve host if it's a function
if (typeof route.action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
const resolvedHost = route.action.target.host(toBaseContext(routeContext)); const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
targetHost = resolvedHost; targetHost = resolvedHost;
this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
} else { } else {
targetHost = route.action.target.host; targetHost = selectedTarget.host;
} }
// Resolve port if it's a function // Resolve port if it's a function
if (typeof route.action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
targetPort = route.action.target.port(toBaseContext(routeContext)); targetPort = selectedTarget.port(toBaseContext(routeContext));
this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`); this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`);
} else { } else {
targetPort = route.action.target.port === 'preserve' ? routeContext.port : route.action.target.port as number; targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
} }
// Select a single host if an array was provided // Select a single host if an array was provided

View File

@@ -12,7 +12,7 @@ export interface ICertStatus {
status: 'valid' | 'pending' | 'expired' | 'error'; status: 'valid' | 'pending' | 'expired' | 'error';
expiryDate?: Date; expiryDate?: Date;
issueDate?: Date; issueDate?: Date;
source: 'static' | 'acme'; source: 'static' | 'acme' | 'custom';
error?: string; error?: string;
} }
@@ -22,6 +22,7 @@ export interface ICertificateData {
ca?: string; ca?: string;
expiryDate: Date; expiryDate: Date;
issueDate: Date; issueDate: Date;
source?: 'static' | 'acme' | 'custom';
} }
export class SmartCertManager { export class SmartCertManager {
@@ -50,6 +51,12 @@ export class SmartCertManager {
// ACME state manager reference // ACME state manager reference
private acmeStateManager: AcmeStateManager | null = null; private acmeStateManager: AcmeStateManager | null = null;
// Custom certificate provision function
private certProvisionFunction?: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>;
// Whether to fallback to ACME if custom provision fails
private certProvisionFallbackToAcme: boolean = true;
constructor( constructor(
private routes: IRouteConfig[], private routes: IRouteConfig[],
private certDir: string = './certs', private certDir: string = './certs',
@@ -89,6 +96,20 @@ export class SmartCertManager {
this.globalAcmeDefaults = defaults; this.globalAcmeDefaults = defaults;
} }
/**
* Set custom certificate provision function
*/
public setCertProvisionFunction(fn: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>): void {
this.certProvisionFunction = fn;
}
/**
* Set whether to fallback to ACME if custom provision fails
*/
public setCertProvisionFallbackToAcme(fallback: boolean): void {
this.certProvisionFallbackToAcme = fallback;
}
/** /**
* Set callback for updating routes (used for challenge routes) * Set callback for updating routes (used for challenge routes)
*/ */
@@ -212,15 +233,6 @@ export class SmartCertManager {
route: IRouteConfig, route: IRouteConfig,
domains: string[] domains: string[]
): Promise<void> { ): Promise<void> {
if (!this.smartAcme) {
throw new Error(
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
'Please ensure you have configured ACME with an email address either:\n' +
'1. In the top-level "acme" configuration\n' +
'2. In the route\'s "tls.acme" configuration'
);
}
const primaryDomain = domains[0]; const primaryDomain = domains[0];
const routeName = route.name || primaryDomain; const routeName = route.name || primaryDomain;
@@ -229,10 +241,68 @@ export class SmartCertManager {
if (existingCert && this.isCertificateValid(existingCert)) { if (existingCert && this.isCertificateValid(existingCert)) {
logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' }); logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
await this.applyCertificate(primaryDomain, existingCert); await this.applyCertificate(primaryDomain, existingCert);
this.updateCertStatus(routeName, 'valid', 'acme', existingCert); this.updateCertStatus(routeName, 'valid', existingCert.source || 'acme', existingCert);
return; return;
} }
// Check for custom provision function first
if (this.certProvisionFunction) {
try {
logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
const result = await this.certProvisionFunction(primaryDomain);
if (result === 'http01') {
logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
// Continue with existing ACME logic below
} else {
// Use custom certificate
const customCert = result as plugins.tsclass.network.ICert;
// Convert to internal certificate format
const certData: ICertificateData = {
cert: customCert.publicKey,
key: customCert.privateKey,
ca: '',
issueDate: new Date(),
expiryDate: this.extractExpiryDate(customCert.publicKey),
source: 'custom'
};
// Store and apply certificate
await this.certStore.saveCertificate(routeName, certData);
await this.applyCertificate(primaryDomain, certData);
this.updateCertStatus(routeName, 'valid', 'custom', certData);
logger.log('info', `Custom certificate applied for ${primaryDomain}`, {
domain: primaryDomain,
expiryDate: certData.expiryDate,
component: 'certificate-manager'
});
return;
}
} catch (error) {
logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
domain: primaryDomain,
error: error.message,
component: 'certificate-manager'
});
// Check if we should fallback to ACME
if (!this.certProvisionFallbackToAcme) {
throw error;
}
logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
}
}
if (!this.smartAcme) {
throw new Error(
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
'Please ensure you have configured ACME with an email address either:\n' +
'1. In the top-level "acme" configuration\n' +
'2. In the route\'s "tls.acme" configuration'
);
}
// Apply renewal threshold from global defaults or route config // Apply renewal threshold from global defaults or route config
const renewThreshold = route.action.tls?.acme?.renewBeforeDays || const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
this.globalAcmeDefaults?.renewThresholdDays || this.globalAcmeDefaults?.renewThresholdDays ||
@@ -280,7 +350,8 @@ export class SmartCertManager {
key: cert.privateKey, key: cert.privateKey,
ca: cert.publicKey, // Use same as cert for now ca: cert.publicKey, // Use same as cert for now
expiryDate: new Date(cert.validUntil), expiryDate: new Date(cert.validUntil),
issueDate: new Date(cert.created) issueDate: new Date(cert.created),
source: 'acme'
}; };
await this.certStore.saveCertificate(routeName, certData); await this.certStore.saveCertificate(routeName, certData);
@@ -328,7 +399,8 @@ export class SmartCertManager {
cert, cert,
key, key,
expiryDate: certInfo.validTo, expiryDate: certInfo.validTo,
issueDate: certInfo.validFrom issueDate: certInfo.validFrom,
source: 'static'
}; };
// Save to store for consistency // Save to store for consistency
@@ -399,6 +471,19 @@ export class SmartCertManager {
return cert.expiryDate > expiryThreshold; return cert.expiryDate > expiryThreshold;
} }
/**
* Extract expiry date from a PEM certificate
*/
private extractExpiryDate(_certPem: string): Date {
// For now, we'll default to 90 days for custom certificates
// In production, you might want to use a proper X.509 parser
// or require the custom cert provider to include expiry info
logger.log('info', 'Using default 90-day expiry for custom certificate', {
component: 'certificate-manager'
});
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
}
/** /**
* Add challenge route to SmartProxy * Add challenge route to SmartProxy

View File

@@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import type { IConnectionRecord } from './models/interfaces.js'; import type { IConnectionRecord } from './models/interfaces.js';
import { logger } from '../../core/utils/logger.js'; import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
import { LifecycleComponent } from '../../core/utils/lifecycle-component.js'; import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
import { cleanupSocket } from '../../core/utils/socket-utils.js'; import { cleanupSocket } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js'; import { WrappedSocket } from '../../core/models/wrapped-socket.js';
@@ -26,6 +27,10 @@ export class ConnectionManager extends LifecycleComponent {
// Cleanup queue for batched processing // Cleanup queue for batched processing
private cleanupQueue: Set<string> = new Set(); private cleanupQueue: Set<string> = new Set();
private cleanupTimer: NodeJS.Timeout | null = null; private cleanupTimer: NodeJS.Timeout | null = null;
private isProcessingCleanup: boolean = false;
// Route-level connection tracking
private connectionsByRoute: Map<string, Set<string>> = new Map();
constructor( constructor(
private smartProxy: SmartProxy private smartProxy: SmartProxy
@@ -56,11 +61,19 @@ export class ConnectionManager extends LifecycleComponent {
public createConnection(socket: plugins.net.Socket | WrappedSocket): IConnectionRecord | null { public createConnection(socket: plugins.net.Socket | WrappedSocket): IConnectionRecord | null {
// Enforce connection limit // Enforce connection limit
if (this.connectionRecords.size >= this.maxConnections) { if (this.connectionRecords.size >= this.maxConnections) {
logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, { // Use deduplicated logging for connection limit
currentConnections: this.connectionRecords.size, connectionLogDeduplicator.log(
maxConnections: this.maxConnections, 'connection-rejected',
component: 'connection-manager' 'warn',
}); 'Global connection limit reached',
{
reason: 'global-limit',
currentConnections: this.connectionRecords.size,
maxConnections: this.maxConnections,
component: 'connection-manager'
},
'global-limit'
);
socket.destroy(); socket.destroy();
return null; return null;
} }
@@ -165,18 +178,53 @@ export class ConnectionManager extends LifecycleComponent {
return this.connectionRecords.size; return this.connectionRecords.size;
} }
/**
* Track connection by route
*/
public trackConnectionByRoute(routeId: string, connectionId: string): void {
if (!this.connectionsByRoute.has(routeId)) {
this.connectionsByRoute.set(routeId, new Set());
}
this.connectionsByRoute.get(routeId)!.add(connectionId);
}
/**
* Remove connection tracking for a route
*/
public removeConnectionByRoute(routeId: string, connectionId: string): void {
if (this.connectionsByRoute.has(routeId)) {
const connections = this.connectionsByRoute.get(routeId)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByRoute.delete(routeId);
}
}
}
/**
* Get connection count by route
*/
public getConnectionCountByRoute(routeId: string): number {
return this.connectionsByRoute.get(routeId)?.size || 0;
}
/** /**
* Initiates cleanup once for a connection * Initiates cleanup once for a connection
*/ */
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void { public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
if (this.smartProxy.settings.enableDetailedLogging) { // Use deduplicated logging for cleanup events
logger.log('info', `Connection cleanup initiated`, { connectionLogDeduplicator.log(
'connection-cleanup',
'info',
`Connection cleanup: ${reason}`,
{
connectionId: record.id, connectionId: record.id,
remoteIP: record.remoteIP, remoteIP: record.remoteIP,
reason, reason,
component: 'connection-manager' component: 'connection-manager'
}); },
} reason
);
if (record.incomingTerminationReason == null) { if (record.incomingTerminationReason == null) {
record.incomingTerminationReason = reason; record.incomingTerminationReason = reason;
@@ -200,10 +248,10 @@ export class ConnectionManager extends LifecycleComponent {
this.cleanupQueue.add(connectionId); this.cleanupQueue.add(connectionId);
// Process immediately if queue is getting large // Process immediately if queue is getting large and not already processing
if (this.cleanupQueue.size >= this.cleanupBatchSize) { if (this.cleanupQueue.size >= this.cleanupBatchSize && !this.isProcessingCleanup) {
this.processCleanupQueue(); this.processCleanupQueue();
} else if (!this.cleanupTimer) { } else if (!this.cleanupTimer && !this.isProcessingCleanup) {
// Otherwise, schedule batch processing // Otherwise, schedule batch processing
this.cleanupTimer = this.setTimeout(() => { this.cleanupTimer = this.setTimeout(() => {
this.processCleanupQueue(); this.processCleanupQueue();
@@ -215,27 +263,40 @@ export class ConnectionManager extends LifecycleComponent {
* Process the cleanup queue in batches * Process the cleanup queue in batches
*/ */
private processCleanupQueue(): void { private processCleanupQueue(): void {
// Prevent concurrent processing
if (this.isProcessingCleanup) {
return;
}
this.isProcessingCleanup = true;
if (this.cleanupTimer) { if (this.cleanupTimer) {
this.clearTimeout(this.cleanupTimer); this.clearTimeout(this.cleanupTimer);
this.cleanupTimer = null; this.cleanupTimer = null;
} }
const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize); try {
// Take a snapshot of items to process
const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize);
// Remove only the items we're processing, not the entire queue! // Remove only the items we're processing from the queue
for (const connectionId of toCleanup) { for (const connectionId of toCleanup) {
this.cleanupQueue.delete(connectionId); this.cleanupQueue.delete(connectionId);
const record = this.connectionRecords.get(connectionId); const record = this.connectionRecords.get(connectionId);
if (record) { if (record) {
this.cleanupConnection(record, record.incomingTerminationReason || 'normal'); this.cleanupConnection(record, record.incomingTerminationReason || 'normal');
}
} }
} } finally {
// Always reset the processing flag
this.isProcessingCleanup = false;
// If there are more in queue, schedule next batch // Check if more items were added while we were processing
if (this.cleanupQueue.size > 0) { if (this.cleanupQueue.size > 0) {
this.cleanupTimer = this.setTimeout(() => { this.cleanupTimer = this.setTimeout(() => {
this.processCleanupQueue(); this.processCleanupQueue();
}, 10); }, 10);
}
} }
} }
@@ -252,6 +313,11 @@ export class ConnectionManager extends LifecycleComponent {
// Track connection termination // Track connection termination
this.smartProxy.securityManager.removeConnectionByIP(record.remoteIP, record.id); this.smartProxy.securityManager.removeConnectionByIP(record.remoteIP, record.id);
// Remove from route tracking
if (record.routeId) {
this.removeConnectionByRoute(record.routeId, record.id);
}
// Remove from metrics tracking // Remove from metrics tracking
if (this.smartProxy.metricsCollector) { if (this.smartProxy.metricsCollector) {
this.smartProxy.metricsCollector.removeConnection(record.id); this.smartProxy.metricsCollector.removeConnection(record.id);
@@ -335,23 +401,34 @@ export class ConnectionManager extends LifecycleComponent {
// Remove the record from the tracking map // Remove the record from the tracking map
this.connectionRecords.delete(record.id); this.connectionRecords.delete(record.id);
// Log connection details // Use deduplicated logging for connection termination
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', // For detailed logging, include more info but still deduplicate by IP+reason
`Connection terminated: ${record.remoteIP}:${record.localPort} (${reason}) - ` + connectionLogDeduplicator.log(
`${plugins.prettyMs(duration)}, IN: ${record.bytesReceived}B, OUT: ${record.bytesSent}B`, 'connection-terminated',
logData 'info',
`Connection terminated: ${record.remoteIP}:${record.localPort}`,
{
...logData,
duration_ms: duration,
bytesIn: record.bytesReceived,
bytesOut: record.bytesSent
},
`${record.remoteIP}-${reason}`
); );
} else { } else {
logger.log('info', // For normal logging, deduplicate by termination reason
`Connection terminated: ${record.remoteIP} (${reason}). Active: ${this.connectionRecords.size}`, connectionLogDeduplicator.log(
'connection-terminated',
'info',
`Connection terminated`,
{ {
connectionId: record.id,
remoteIP: record.remoteIP, remoteIP: record.remoteIP,
reason, reason,
activeConnections: this.connectionRecords.size, activeConnections: this.connectionRecords.size,
component: 'connection-manager' component: 'connection-manager'
} },
reason // Group by termination reason
); );
} }
} }

View File

@@ -121,6 +121,11 @@ export class HttpProxyBridge {
proxySocket.on('error', reject); proxySocket.on('error', reject);
}); });
// Send client IP information header first (custom protocol)
// Format: "CLIENT_IP:<ip>\r\n"
const clientIPHeader = Buffer.from(`CLIENT_IP:${record.remoteIP}\r\n`);
proxySocket.write(clientIPHeader);
// Send initial chunk if present // Send initial chunk if present
if (initialChunk) { if (initialChunk) {
// Count the initial chunk bytes // Count the initial chunk bytes

View File

@@ -135,6 +135,12 @@ export interface ISmartProxyOptions {
* or a static certificate object for immediate provisioning. * or a static certificate object for immediate provisioning.
*/ */
certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>; certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
/**
* Whether to fallback to ACME if custom certificate provision fails.
* Default: true
*/
certProvisionFallbackToAcme?: boolean;
} }
/** /**
@@ -165,6 +171,7 @@ export interface IConnectionRecord {
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
hasReceivedInitialData: boolean; // Whether initial data has been received hasReceivedInitialData: boolean; // Whether initial data has been received
routeConfig?: IRouteConfig; // Associated route config for this connection routeConfig?: IRouteConfig; // Associated route config for this connection
routeId?: string; // ID of the route this connection is associated with
// Target information (for dynamic port/host mapping) // Target information (for dynamic port/host mapping)
targetHost?: string; // Resolved target host targetHost?: string; // Resolved target host

View File

@@ -46,11 +46,36 @@ export interface IRouteMatch {
/** /**
* Target configuration for forwarding * Target-specific match criteria for sub-routing within a route
*/
export interface ITargetMatch {
ports?: number[]; // Match specific ports from the route
path?: string; // Match specific paths (supports wildcards like /api/*)
headers?: Record<string, string | RegExp>; // Match specific HTTP headers
method?: string[]; // Match specific HTTP methods (GET, POST, etc.)
}
/**
* Target configuration for forwarding with sub-matching and overrides
*/ */
export interface IRouteTarget { export interface IRouteTarget {
// Optional sub-matching criteria within the route
match?: ITargetMatch;
// Target destination
host: string | string[] | ((context: IRouteContext) => string | string[]); // Host or hosts with optional function for dynamic resolution host: string | string[] | ((context: IRouteContext) => string | string[]); // Host or hosts with optional function for dynamic resolution
port: number | 'preserve' | ((context: IRouteContext) => number); // Port with optional function for dynamic mapping (use 'preserve' to keep the incoming port) port: number | 'preserve' | ((context: IRouteContext) => number); // Port with optional function for dynamic mapping (use 'preserve' to keep the incoming port)
// Optional target-specific overrides (these override route-level settings)
tls?: IRouteTls; // Override route-level TLS settings
websocket?: IRouteWebSocket; // Override route-level WebSocket settings
loadBalancing?: IRouteLoadBalancing; // Override route-level load balancing
sendProxyProtocol?: boolean; // Override route-level proxy protocol setting
headers?: IRouteHeaders; // Override route-level headers
advanced?: IRouteAdvanced; // Override route-level advanced settings
// Priority for matching (higher values are checked first, default: 0)
priority?: number;
} }
/** /**
@@ -221,19 +246,20 @@ export interface IRouteAction {
// Basic routing // Basic routing
type: TRouteActionType; type: TRouteActionType;
// Target for forwarding // Targets for forwarding (array supports multiple targets with sub-matching)
target?: IRouteTarget; // Required for 'forward' action type
targets?: IRouteTarget[];
// TLS handling // TLS handling (default for all targets, can be overridden per target)
tls?: IRouteTls; tls?: IRouteTls;
// WebSocket support // WebSocket support (default for all targets, can be overridden per target)
websocket?: IRouteWebSocket; websocket?: IRouteWebSocket;
// Load balancing options // Load balancing options (default for all targets, can be overridden per target)
loadBalancing?: IRouteLoadBalancing; loadBalancing?: IRouteLoadBalancing;
// Advanced options // Advanced options (default for all targets, can be overridden per target)
advanced?: IRouteAdvanced; advanced?: IRouteAdvanced;
// Additional options for backend-specific settings // Additional options for backend-specific settings
@@ -251,7 +277,7 @@ export interface IRouteAction {
// Socket handler function (when type is 'socket-handler') // Socket handler function (when type is 'socket-handler')
socketHandler?: TSocketHandler; socketHandler?: TSocketHandler;
// PROXY protocol support // PROXY protocol support (default for all targets, can be overridden per target)
sendProxyProtocol?: boolean; sendProxyProtocol?: boolean;
} }

View File

@@ -123,39 +123,43 @@ export class NFTablesManager {
private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions { private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions {
const { action } = route; const { action } = route;
// Ensure we have a target // Ensure we have targets
if (!action.target) { if (!action.targets || action.targets.length === 0) {
throw new Error('Route must have a target to use NFTables forwarding'); throw new Error('Route must have targets to use NFTables forwarding');
} }
// NFTables can only handle a single target, so we use the first target without match criteria
// or the first target if all have match criteria
const defaultTarget = action.targets.find(t => !t.match) || action.targets[0];
// Convert port specifications // Convert port specifications
const fromPorts = this.expandPortRange(route.match.ports); const fromPorts = this.expandPortRange(route.match.ports);
// Determine target port // Determine target port
let toPorts: number | PortRange | Array<number | PortRange>; let toPorts: number | PortRange | Array<number | PortRange>;
if (action.target.port === 'preserve') { if (defaultTarget.port === 'preserve') {
// 'preserve' means use the same ports as the source // 'preserve' means use the same ports as the source
toPorts = fromPorts; toPorts = fromPorts;
} else if (typeof action.target.port === 'function') { } else if (typeof defaultTarget.port === 'function') {
// For function-based ports, we can't determine at setup time // For function-based ports, we can't determine at setup time
// Use the "preserve" approach and let NFTables handle it // Use the "preserve" approach and let NFTables handle it
toPorts = fromPorts; toPorts = fromPorts;
} else { } else {
toPorts = action.target.port; toPorts = defaultTarget.port;
} }
// Determine target host // Determine target host
let toHost: string; let toHost: string;
if (typeof action.target.host === 'function') { if (typeof defaultTarget.host === 'function') {
// Can't determine at setup time, use localhost as a placeholder // Can't determine at setup time, use localhost as a placeholder
// and rely on run-time handling // and rely on run-time handling
toHost = 'localhost'; toHost = 'localhost';
} else if (Array.isArray(action.target.host)) { } else if (Array.isArray(defaultTarget.host)) {
// Use first host for now - NFTables will do simple round-robin // Use first host for now - NFTables will do simple round-robin
toHost = action.target.host[0]; toHost = defaultTarget.host[0];
} else { } else {
toHost = action.target.host; toHost = defaultTarget.host;
} }
// Create options // Create options

View File

@@ -1,8 +1,9 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js'; import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
import { logger } from '../../core/utils/logger.js'; import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
// Route checking functions have been removed // Route checking functions have been removed
import type { IRouteConfig, IRouteAction } from './models/route-types.js'; import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js';
import type { IRouteContext } from '../../core/models/route-context.js'; import type { IRouteContext } from '../../core/models/route-context.js';
import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js'; import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js'; import { WrappedSocket } from '../../core/models/wrapped-socket.js';
@@ -89,7 +90,13 @@ export class RouteConnectionHandler {
// Note: For wrapped sockets, this will use the underlying socket IP until PROXY protocol is parsed // Note: For wrapped sockets, this will use the underlying socket IP until PROXY protocol is parsed
const ipValidation = this.smartProxy.securityManager.validateIP(wrappedSocket.remoteAddress || ''); const ipValidation = this.smartProxy.securityManager.validateIP(wrappedSocket.remoteAddress || '');
if (!ipValidation.allowed) { if (!ipValidation.allowed) {
logger.log('warn', `Connection rejected`, { remoteIP: wrappedSocket.remoteAddress, reason: ipValidation.reason, component: 'route-handler' }); connectionLogDeduplicator.log(
'ip-rejected',
'warn',
`Connection rejected from ${wrappedSocket.remoteAddress}`,
{ remoteIP: wrappedSocket.remoteAddress, reason: ipValidation.reason, component: 'route-handler' },
wrappedSocket.remoteAddress
);
cleanupSocket(wrappedSocket.socket, `rejected-${ipValidation.reason}`, { immediate: true }); cleanupSocket(wrappedSocket.socket, `rejected-${ipValidation.reason}`, { immediate: true });
return; return;
} }
@@ -563,12 +570,20 @@ export class RouteConnectionHandler {
); );
if (!isIPAllowed) { if (!isIPAllowed) {
logger.log('warn', `IP ${remoteIP} blocked by route security for route ${route.name || 'unnamed'} (connection: ${connectionId})`, { // Deduplicated logging for route IP blocks
connectionId, connectionLogDeduplicator.log(
remoteIP, 'ip-rejected',
routeName: route.name || 'unnamed', 'warn',
component: 'route-handler' `IP blocked by route security`,
}); {
connectionId,
remoteIP,
routeName: route.name || 'unnamed',
reason: 'route-ip-blocked',
component: 'route-handler'
},
remoteIP
);
socket.end(); socket.end();
this.smartProxy.connectionManager.cleanupConnection(record, 'route_ip_blocked'); this.smartProxy.connectionManager.cleanupConnection(record, 'route_ip_blocked');
return; return;
@@ -577,14 +592,28 @@ export class RouteConnectionHandler {
// Check max connections per route // Check max connections per route
if (route.security.maxConnections !== undefined) { if (route.security.maxConnections !== undefined) {
// TODO: Implement per-route connection tracking const routeId = route.id || route.name || 'unnamed';
// For now, log that this feature is not yet implemented const currentConnections = this.smartProxy.connectionManager.getConnectionCountByRoute(routeId);
if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('warn', `Route ${route.name} has maxConnections=${route.security.maxConnections} configured but per-route connection limits are not yet implemented`, { if (currentConnections >= route.security.maxConnections) {
connectionId, // Deduplicated logging for route connection limits
routeName: route.name, connectionLogDeduplicator.log(
component: 'route-handler' 'connection-rejected',
}); 'warn',
`Route connection limit reached`,
{
connectionId,
routeName: route.name,
currentConnections,
maxConnections: route.security.maxConnections,
reason: 'route-limit',
component: 'route-handler'
},
`route-limit-${route.name}`
);
socket.end();
this.smartProxy.connectionManager.cleanupConnection(record, 'route_connection_limit');
return;
} }
} }
@@ -628,6 +657,80 @@ export class RouteConnectionHandler {
} }
} }
/**
* Select the appropriate target from the targets array based on sub-matching criteria
*/
private selectTarget(
targets: IRouteTarget[],
context: {
port: number;
path?: string;
headers?: Record<string, string>;
method?: string;
}
): IRouteTarget | null {
// Sort targets by priority (higher first)
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Find the first matching target
for (const target of sortedTargets) {
if (!target.match) {
// No match criteria means this is a default/fallback target
return target;
}
// Check port match
if (target.match.ports && !target.match.ports.includes(context.port)) {
continue;
}
// Check path match (supports wildcards)
if (target.match.path && context.path) {
const pathPattern = target.match.path.replace(/\*/g, '.*');
const pathRegex = new RegExp(`^${pathPattern}$`);
if (!pathRegex.test(context.path)) {
continue;
}
}
// Check method match
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
continue;
}
// Check headers match
if (target.match.headers && context.headers) {
let headersMatch = true;
for (const [key, pattern] of Object.entries(target.match.headers)) {
const headerValue = context.headers[key.toLowerCase()];
if (!headerValue) {
headersMatch = false;
break;
}
if (pattern instanceof RegExp) {
if (!pattern.test(headerValue)) {
headersMatch = false;
break;
}
} else if (headerValue !== pattern) {
headersMatch = false;
break;
}
}
if (!headersMatch) {
continue;
}
}
// All criteria matched
return target;
}
// No matching target found, return the first target without match criteria (default)
return sortedTargets.find(t => !t.match) || null;
}
/** /**
* Handle a forward action for a route * Handle a forward action for a route
*/ */
@@ -642,6 +745,10 @@ export class RouteConnectionHandler {
// Store the route config in the connection record for metrics and other uses // Store the route config in the connection record for metrics and other uses
record.routeConfig = route; record.routeConfig = route;
record.routeId = route.id || route.name || 'unnamed';
// Track connection by route
this.smartProxy.connectionManager.trackConnectionByRoute(record.routeId, record.id);
// Check if this route uses NFTables for forwarding // Check if this route uses NFTables for forwarding
if (action.forwardingEngine === 'nftables') { if (action.forwardingEngine === 'nftables') {
@@ -698,14 +805,37 @@ export class RouteConnectionHandler {
return; return;
} }
// We should have a target configuration for forwarding // Select the appropriate target from the targets array
if (!action.target) { if (!action.targets || action.targets.length === 0) {
logger.log('error', `Forward action missing target configuration for connection ${connectionId}`, { logger.log('error', `Forward action missing targets configuration for connection ${connectionId}`, {
connectionId, connectionId,
component: 'route-handler' component: 'route-handler'
}); });
socket.end(); socket.end();
this.smartProxy.connectionManager.cleanupConnection(record, 'missing_target'); this.smartProxy.connectionManager.cleanupConnection(record, 'missing_targets');
return;
}
// Create context for target selection
const targetSelectionContext = {
port: record.localPort,
path: undefined, // Will be populated from HTTP headers if available
headers: undefined, // Will be populated from HTTP headers if available
method: undefined // Will be populated from HTTP headers if available
};
// TODO: Extract path, headers, and method from initialChunk if it's HTTP
// For now, we'll select based on port only
const selectedTarget = this.selectTarget(action.targets, targetSelectionContext);
if (!selectedTarget) {
logger.log('error', `No matching target found for connection ${connectionId}`, {
connectionId,
port: targetSelectionContext.port,
component: 'route-handler'
});
socket.end();
this.smartProxy.connectionManager.cleanupConnection(record, 'no_matching_target');
return; return;
} }
@@ -726,9 +856,9 @@ export class RouteConnectionHandler {
// Determine host using function or static value // Determine host using function or static value
let targetHost: string | string[]; let targetHost: string | string[];
if (typeof action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
try { try {
targetHost = action.target.host(routeContext); targetHost = selectedTarget.host(routeContext);
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, { logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, {
connectionId, connectionId,
@@ -747,7 +877,7 @@ export class RouteConnectionHandler {
return; return;
} }
} else { } else {
targetHost = action.target.host; targetHost = selectedTarget.host;
} }
// If an array of hosts, select one randomly for load balancing // If an array of hosts, select one randomly for load balancing
@@ -757,9 +887,9 @@ export class RouteConnectionHandler {
// Determine port using function or static value // Determine port using function or static value
let targetPort: number; let targetPort: number;
if (typeof action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
try { try {
targetPort = action.target.port(routeContext); targetPort = selectedTarget.port(routeContext);
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, { logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, {
connectionId, connectionId,
@@ -780,20 +910,27 @@ export class RouteConnectionHandler {
this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error'); this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error');
return; return;
} }
} else if (action.target.port === 'preserve') { } else if (selectedTarget.port === 'preserve') {
// Use incoming port if port is 'preserve' // Use incoming port if port is 'preserve'
targetPort = record.localPort; targetPort = record.localPort;
} else { } else {
// Use static port from configuration // Use static port from configuration
targetPort = action.target.port; targetPort = selectedTarget.port;
} }
// Store the resolved host in the context // Store the resolved host in the context
routeContext.targetHost = selectedHost; routeContext.targetHost = selectedHost;
// Get effective settings (target overrides route-level settings)
const effectiveTls = selectedTarget.tls || action.tls;
const effectiveWebsocket = selectedTarget.websocket || action.websocket;
const effectiveSendProxyProtocol = selectedTarget.sendProxyProtocol !== undefined
? selectedTarget.sendProxyProtocol
: action.sendProxyProtocol;
// Determine if this needs TLS handling // Determine if this needs TLS handling
if (action.tls) { if (effectiveTls) {
switch (action.tls.mode) { switch (effectiveTls.mode) {
case 'passthrough': case 'passthrough':
// For TLS passthrough, just forward directly // For TLS passthrough, just forward directly
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
@@ -820,9 +957,9 @@ export class RouteConnectionHandler {
// For TLS termination, use HttpProxy // For TLS termination, use HttpProxy
if (this.smartProxy.httpProxyBridge.getHttpProxy()) { if (this.smartProxy.httpProxyBridge.getHttpProxy()) {
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host} for connection ${connectionId}`, { logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host} for connection ${connectionId}`, {
connectionId, connectionId,
targetHost: action.target.host, targetHost: selectedTarget.host,
component: 'route-handler' component: 'route-handler'
}); });
} }
@@ -896,10 +1033,10 @@ export class RouteConnectionHandler {
} else { } else {
// Basic forwarding // Basic forwarding
if (this.smartProxy.settings.enableDetailedLogging) { if (this.smartProxy.settings.enableDetailedLogging) {
logger.log('info', `Using basic forwarding to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host}:${action.target.port} for connection ${connectionId}`, { logger.log('info', `Using basic forwarding to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host}:${selectedTarget.port} for connection ${connectionId}`, {
connectionId, connectionId,
targetHost: action.target.host, targetHost: selectedTarget.host,
targetPort: action.target.port, targetPort: selectedTarget.port,
component: 'route-handler' component: 'route-handler'
}); });
} }
@@ -907,27 +1044,27 @@ export class RouteConnectionHandler {
// Get the appropriate host value // Get the appropriate host value
let targetHost: string; let targetHost: string;
if (typeof action.target.host === 'function') { if (typeof selectedTarget.host === 'function') {
// For function-based host, use the same routeContext created earlier // For function-based host, use the same routeContext created earlier
const hostResult = action.target.host(routeContext); const hostResult = selectedTarget.host(routeContext);
targetHost = Array.isArray(hostResult) targetHost = Array.isArray(hostResult)
? hostResult[Math.floor(Math.random() * hostResult.length)] ? hostResult[Math.floor(Math.random() * hostResult.length)]
: hostResult; : hostResult;
} else { } else {
// For static host value // For static host value
targetHost = Array.isArray(action.target.host) targetHost = Array.isArray(selectedTarget.host)
? action.target.host[Math.floor(Math.random() * action.target.host.length)] ? selectedTarget.host[Math.floor(Math.random() * selectedTarget.host.length)]
: action.target.host; : selectedTarget.host;
} }
// Determine port - either function-based, static, or preserve incoming port // Determine port - either function-based, static, or preserve incoming port
let targetPort: number; let targetPort: number;
if (typeof action.target.port === 'function') { if (typeof selectedTarget.port === 'function') {
targetPort = action.target.port(routeContext); targetPort = selectedTarget.port(routeContext);
} else if (action.target.port === 'preserve') { } else if (selectedTarget.port === 'preserve') {
targetPort = record.localPort; targetPort = record.localPort;
} else { } else {
targetPort = action.target.port; targetPort = selectedTarget.port;
} }
// Update the connection record and context with resolved values // Update the connection record and context with resolved values
@@ -960,6 +1097,10 @@ export class RouteConnectionHandler {
// Store the route config in the connection record for metrics and other uses // Store the route config in the connection record for metrics and other uses
record.routeConfig = route; record.routeConfig = route;
record.routeId = route.id || route.name || 'unnamed';
// Track connection by route
this.smartProxy.connectionManager.trackConnectionByRoute(record.routeId, record.id);
if (!route.action.socketHandler) { if (!route.action.socketHandler) {
logger.log('error', 'socket-handler action missing socketHandler function', { logger.log('error', 'socket-handler action missing socketHandler function', {

View File

@@ -1,5 +1,7 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import type { SmartProxy } from './smart-proxy.js'; import type { SmartProxy } from './smart-proxy.js';
import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
/** /**
* Handles security aspects like IP tracking, rate limiting, and authorization * Handles security aspects like IP tracking, rate limiting, and authorization
@@ -7,8 +9,12 @@ import type { SmartProxy } from './smart-proxy.js';
export class SecurityManager { export class SecurityManager {
private connectionsByIP: Map<string, Set<string>> = new Map(); private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map(); private connectionRateByIP: Map<string, number[]> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(private smartProxy: SmartProxy) {} constructor(private smartProxy: SmartProxy) {
// Start periodic cleanup every 60 seconds
this.startPeriodicCleanup();
}
/** /**
* Get connections count by IP * Get connections count by IP
@@ -164,7 +170,76 @@ export class SecurityManager {
* Clears all IP tracking data (for shutdown) * Clears all IP tracking data (for shutdown)
*/ */
public clearIPTracking(): void { public clearIPTracking(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.connectionsByIP.clear(); this.connectionsByIP.clear();
this.connectionRateByIP.clear(); this.connectionRateByIP.clear();
} }
/**
* Start periodic cleanup of expired data
*/
private startPeriodicCleanup(): void {
this.cleanupInterval = setInterval(() => {
this.performCleanup();
}, 60000); // Run every minute
// Unref the timer so it doesn't keep the process alive
if (this.cleanupInterval.unref) {
this.cleanupInterval.unref();
}
}
/**
* Perform cleanup of expired rate limits and empty IP entries
*/
private performCleanup(): void {
const now = Date.now();
const minute = 60 * 1000;
let cleanedRateLimits = 0;
let cleanedIPs = 0;
// Clean up expired rate limit timestamps
for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
const validTimestamps = timestamps.filter(time => now - time < minute);
if (validTimestamps.length === 0) {
// No valid timestamps, remove the IP entry
this.connectionRateByIP.delete(ip);
cleanedRateLimits++;
} else if (validTimestamps.length < timestamps.length) {
// Some timestamps expired, update with valid ones
this.connectionRateByIP.set(ip, validTimestamps);
}
}
// Clean up IPs with no active connections
for (const [ip, connections] of this.connectionsByIP.entries()) {
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
cleanedIPs++;
}
}
// Log cleanup stats if anything was cleaned
if (cleanedRateLimits > 0 || cleanedIPs > 0) {
if (this.smartProxy.settings.enableDetailedLogging) {
connectionLogDeduplicator.log(
'ip-cleanup',
'debug',
'IP tracking cleanup completed',
{
cleanedRateLimits,
cleanedIPs,
remainingIPs: this.connectionsByIP.size,
remainingRateLimits: this.connectionRateByIP.size,
component: 'security-manager'
},
'periodic-cleanup'
);
}
}
}
} }

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import { logger } from '../../core/utils/logger.js'; import { logger } from '../../core/utils/logger.js';
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
// Importing required components // Importing required components
import { ConnectionManager } from './connection-manager.js'; import { ConnectionManager } from './connection-manager.js';
@@ -242,6 +243,16 @@ export class SmartProxy extends plugins.EventEmitter {
certManager.setGlobalAcmeDefaults(this.settings.acme); certManager.setGlobalAcmeDefaults(this.settings.acme);
} }
// Pass down the custom certificate provision function if available
if (this.settings.certProvisionFunction) {
certManager.setCertProvisionFunction(this.settings.certProvisionFunction);
}
// Pass down the fallback to ACME setting
if (this.settings.certProvisionFallbackToAcme !== undefined) {
certManager.setCertProvisionFallbackToAcme(this.settings.certProvisionFallbackToAcme);
}
await certManager.initialize(); await certManager.initialize();
return certManager; return certManager;
} }
@@ -516,6 +527,9 @@ export class SmartProxy extends plugins.EventEmitter {
// Stop metrics collector // Stop metrics collector
this.metricsCollector.stop(); this.metricsCollector.stop();
// Flush any pending deduplicated logs
connectionLogDeduplicator.flushAll();
logger.log('info', 'SmartProxy shutdown complete.'); logger.log('info', 'SmartProxy shutdown complete.');
} }

View File

@@ -65,24 +65,18 @@ export class ThroughputTracker {
return { in: 0, out: 0 }; return { in: 0, out: 0 };
} }
// Sum bytes in the window // Calculate total bytes in window
const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0); const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0);
const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0); const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0);
// Calculate actual window duration (might be less than requested if not enough data) // Use actual number of seconds covered by samples for accurate rate
const actualWindowSeconds = Math.min( const oldestSampleTime = relevantSamples[0].timestamp;
windowSeconds, const newestSampleTime = relevantSamples[relevantSamples.length - 1].timestamp;
(now - relevantSamples[0].timestamp) / 1000 const actualSeconds = Math.max(1, (newestSampleTime - oldestSampleTime) / 1000 + 1);
);
// Avoid division by zero
if (actualWindowSeconds === 0) {
return { in: 0, out: 0 };
}
return { return {
in: Math.round(totalBytesIn / actualWindowSeconds), in: Math.round(totalBytesIn / actualSeconds),
out: Math.round(totalBytesOut / actualWindowSeconds) out: Math.round(totalBytesOut / actualSeconds)
}; };
} }

View File

@@ -42,7 +42,7 @@ export function createHttpRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target targets: [target]
}; };
// Create the route config // Create the route config
@@ -82,7 +82,7 @@ export function createHttpsTerminateRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target, targets: [target],
tls: { tls: {
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate', mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
certificate: options.certificate || 'auto' certificate: options.certificate || 'auto'
@@ -152,7 +152,7 @@ export function createHttpsPassthroughRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target, targets: [target],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }
@@ -243,7 +243,7 @@ export function createLoadBalancerRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target targets: [target]
}; };
// Add TLS configuration if provided // Add TLS configuration if provided
@@ -303,7 +303,7 @@ export function createApiRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target targets: [target]
}; };
// Add TLS configuration if using HTTPS // Add TLS configuration if using HTTPS
@@ -374,7 +374,7 @@ export function createWebSocketRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target, targets: [target],
websocket: { websocket: {
enabled: true, enabled: true,
pingInterval: options.pingInterval || 30000, // 30 seconds pingInterval: options.pingInterval || 30000, // 30 seconds
@@ -432,10 +432,10 @@ export function createPortMappingRoute(options: {
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: options.targetHost, host: options.targetHost,
port: options.portMapper port: options.portMapper
} }]
}; };
// Create the route config // Create the route config
@@ -500,10 +500,10 @@ export function createDynamicRoute(options: {
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: options.targetHost, host: options.targetHost,
port: options.portMapper port: options.portMapper
} }]
}; };
// Create the route config // Create the route config
@@ -548,10 +548,10 @@ export function createSmartLoadBalancer(options: {
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: hostSelector, host: hostSelector,
port: options.portMapper port: options.portMapper
} }]
}; };
// Create the route config // Create the route config
@@ -609,10 +609,10 @@ export function createNfTablesRoute(
// Create route action // Create route action
const action: IRouteAction = { const action: IRouteAction = {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
}, }],
forwardingEngine: 'nftables', forwardingEngine: 'nftables',
nftables: { nftables: {
protocol: options.protocol || 'tcp', protocol: options.protocol || 'tcp',

View File

@@ -24,10 +24,10 @@ export function createHttpRoute(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
} }]
}, },
name: options.name || `HTTP: ${Array.isArray(domains) ? domains.join(', ') : domains}` name: options.name || `HTTP: ${Array.isArray(domains) ? domains.join(', ') : domains}`
}; };
@@ -53,10 +53,10 @@ export function createHttpsTerminateRoute(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
}, }],
tls: { tls: {
mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate', mode: options.reencrypt ? 'terminate-and-reencrypt' : 'terminate',
certificate: options.certificate || 'auto' certificate: options.certificate || 'auto'
@@ -83,10 +83,10 @@ export function createHttpsPassthroughRoute(
}, },
action: { action: {
type: 'forward', type: 'forward',
target: { targets: [{
host: target.host, host: target.host,
port: target.port port: target.port
}, }],
tls: { tls: {
mode: 'passthrough' mode: 'passthrough'
} }

View File

@@ -66,12 +66,9 @@ export function mergeRouteConfigs(
// Otherwise merge the action properties // Otherwise merge the action properties
mergedRoute.action = { ...mergedRoute.action }; mergedRoute.action = { ...mergedRoute.action };
// Merge target // Merge targets
if (overrideRoute.action.target) { if (overrideRoute.action.targets) {
mergedRoute.action.target = { mergedRoute.action.targets = overrideRoute.action.targets;
...mergedRoute.action.target,
...overrideRoute.action.target
};
} }
// Merge TLS options // Merge TLS options

View File

@@ -102,29 +102,43 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
errors.push(`Invalid action type: ${action.type}`); errors.push(`Invalid action type: ${action.type}`);
} }
// Validate target for 'forward' action // Validate targets for 'forward' action
if (action.type === 'forward') { if (action.type === 'forward') {
if (!action.target) { if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) {
errors.push('Target is required for forward action'); errors.push('Targets array is required for forward action');
} else { } else {
// Validate target host // Validate each target
if (!action.target.host) { action.targets.forEach((target, index) => {
errors.push('Target host is required'); // Validate target host
} else if (typeof action.target.host !== 'string' && if (!target.host) {
!Array.isArray(action.target.host) && errors.push(`Target[${index}] host is required`);
typeof action.target.host !== 'function') { } else if (typeof target.host !== 'string' &&
errors.push('Target host must be a string, array of strings, or function'); !Array.isArray(target.host) &&
} typeof target.host !== 'function') {
errors.push(`Target[${index}] host must be a string, array of strings, or function`);
}
// Validate target port // Validate target port
if (action.target.port === undefined) { if (target.port === undefined) {
errors.push('Target port is required'); errors.push(`Target[${index}] port is required`);
} else if (typeof action.target.port !== 'number' && } else if (typeof target.port !== 'number' &&
typeof action.target.port !== 'function') { typeof target.port !== 'function' &&
errors.push('Target port must be a number or a function'); target.port !== 'preserve') {
} else if (typeof action.target.port === 'number' && !isValidPort(action.target.port)) { errors.push(`Target[${index}] port must be a number, 'preserve', or a function`);
errors.push('Target port must be between 1 and 65535'); } else if (typeof target.port === 'number' && !isValidPort(target.port)) {
} errors.push(`Target[${index}] port must be between 1 and 65535`);
}
// Validate match criteria if present
if (target.match) {
if (target.match.ports && !Array.isArray(target.match.ports)) {
errors.push(`Target[${index}] match.ports must be an array`);
}
if (target.match.method && !Array.isArray(target.match.method)) {
errors.push(`Target[${index}] match.method must be an array`);
}
}
});
} }
// Validate TLS options for forward actions // Validate TLS options for forward actions
@@ -242,7 +256,10 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType:
switch (actionType) { switch (actionType) {
case 'forward': case 'forward':
return !!route.action.target && !!route.action.target.host && !!route.action.target.port; return !!route.action.targets &&
Array.isArray(route.action.targets) &&
route.action.targets.length > 0 &&
route.action.targets.every(t => t.host && t.port !== undefined);
case 'socket-handler': case 'socket-handler':
return !!route.action.socketHandler && typeof route.action.socketHandler === 'function'; return !!route.action.socketHandler && typeof route.action.socketHandler === 'function';
default: default: