Compare commits

..

27 Commits

Author SHA1 Message Date
Juergen Kunz
36068a6d92 feat(protocols): refactor protocol utilities into centralized protocols module
Some checks failed
Default (tags) / security (push) Successful in 55s
Default (tags) / test (push) Failing after 30m45s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 22:37:45 +00:00
Juergen Kunz
d47b048517 feat(detection): add centralized protocol detection module
- Created ts/detection module for unified protocol detection
- Implemented TLS and HTTP detectors with fragmentation support
- Moved TLS detection logic from existing code to centralized module
- Updated RouteConnectionHandler to use ProtocolDetector for both TLS and HTTP
- Refactored ACME HTTP parsing to use detection module
- Added comprehensive tests for detection functionality
- Eliminated duplicate protocol detection code across codebase

This centralizes all non-destructive protocol detection into a single module,
improving code organization and reducing duplication between ACME and routing.
2025-07-21 19:40:01 +00:00
Juergen Kunz
c84947068c BREAKING_CHANGE(core): remove legacy forwarding module in favor of route-based system
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 30m40s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
- Removed the forwarding namespace export from main index
- Removed TForwardingType and all forwarding handlers
- Consolidated route helper functions into route-helpers.ts
- All functionality is now available through the route-based system
- Users must migrate from forwarding.* imports to direct route helper imports
2025-07-21 18:44:59 +00:00
Juergen Kunz
26f7431111 fix(docs): update documentation to improve clarity
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 31m10s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 12:23:22 +00:00
Juergen Kunz
aa6ddbc4a6 BREAKING_CHANGE(routing): refactor route configuration to support multiple targets
Some checks failed
Default (tags) / security (push) Successful in 53s
Default (tags) / test (push) Failing after 31m2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-21 08:52:07 +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
Juergen Kunz
4587940f38 19.6.12
Some checks failed
Default (tags) / security (push) Successful in 1m28s
Default (tags) / test (push) Failing after 29m14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 13:19:56 +00:00
Juergen Kunz
82ca0381e9 fix(metrics): fix metrics 2025-06-23 13:19:39 +00:00
Juergen Kunz
7bf15e72f9 19.6.11
Some checks failed
Default (tags) / security (push) Successful in 1m29s
Default (tags) / test (push) Failing after 29m11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 13:07:46 +00:00
Juergen Kunz
caa15e539e fix(metrics): fix metrics 2025-06-23 13:07:30 +00:00
Juergen Kunz
cc9e76fade 19.6.10
Some checks failed
Default (tags) / security (push) Successful in 1m31s
Default (tags) / test (push) Failing after 28m39s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-23 09:35:58 +00:00
Juergen Kunz
8df0333dc3 fix(metrics): fix metrics 2025-06-23 09:35:37 +00:00
134 changed files with 6978 additions and 5554 deletions

View File

@@ -1,5 +1,5 @@
{ {
"expiryDate": "2025-09-21T08:37:03.077Z", "expiryDate": "2025-10-19T22:36:33.093Z",
"issueDate": "2025-06-23T08:37:03.077Z", "issueDate": "2025-07-21T22:36:33.093Z",
"savedAt": "2025-06-23T08:37:03.078Z" "savedAt": "2025-07-21T22:36:33.094Z"
} }

View File

@@ -1,5 +1,39 @@
# Changelog # Changelog
## 2025-07-21 - 21.1.0 - feat(protocols)
Refactor protocol utilities into centralized protocols module
- Moved TLS utilities from `ts/tls/` to `ts/protocols/tls/`
- Created centralized protocol modules for HTTP, WebSocket, Proxy, and TLS
- Core utilities now delegate to protocol modules for parsing and utilities
- Maintains backward compatibility through re-exports in original locations
- Improves code organization and separation of concerns
## 2025-07-22 - 21.0.0 - BREAKING_CHANGE(forwarding)
Remove legacy forwarding module
- Removed the `forwarding` namespace export from main index
- Removed TForwardingType and all forwarding handlers
- Consolidated route helper functions into route-helpers.ts
- All functionality is now available through the route-based system
- MIGRATION: Replace `import { forwarding } from '@push.rocks/smartproxy'` with direct imports of route helpers
## 2025-07-21 - 20.0.2 - fix(docs)
Update documentation to improve clarity
- Enhanced readme with clearer breaking change warning for v20.0.0
- Fixed example email address from ssl@bleu.de to ssl@example.com
- Added load balancing and failover features to feature list
- Improved documentation structure and examples
## 2025-07-20 - 20.0.1 - 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.9", "version": "21.1.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",
@@ -51,7 +51,8 @@
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", "npmextra.json",
"readme.md" "readme.md",
"changelog.md"
], ],
"browserslist": [ "browserslist": [
"last 1 chrome versions" "last 1 chrome versions"

View File

@@ -0,0 +1,169 @@
# SmartProxy Byte Counting Audit Report
## Executive Summary
After a comprehensive audit of the SmartProxy codebase, I can confirm that **byte counting is implemented correctly** with no instances of double counting. Each byte transferred through the proxy is counted exactly once in each direction.
## Byte Counting Implementation
### 1. Core Tracking Mechanisms
SmartProxy uses two complementary tracking systems:
1. **Connection Records** (`IConnectionRecord`):
- `bytesReceived`: Total bytes received from client
- `bytesSent`: Total bytes sent to client
2. **MetricsCollector**:
- Global throughput tracking via `ThroughputTracker`
- Per-connection byte tracking for route/IP metrics
- Called via `recordBytes(connectionId, bytesIn, bytesOut)`
### 2. Where Bytes Are Counted
Bytes are counted in only two files:
#### a) `route-connection-handler.ts`
- **Line 351**: TLS alert bytes when no SNI is provided
- **Lines 1286-1301**: Data forwarding callbacks in `setupBidirectionalForwarding()`
#### b) `http-proxy-bridge.ts`
- **Line 127**: Initial TLS chunk for HttpProxy connections
- **Lines 142-154**: Data forwarding callbacks in `setupBidirectionalForwarding()`
## Connection Flow Analysis
### 1. Direct TCP Connection (No TLS)
```
Client → SmartProxy → Target Server
```
1. Connection arrives at `RouteConnectionHandler.handleConnection()`
2. For non-TLS ports, immediately routes via `routeConnection()`
3. `setupDirectConnection()` creates target connection
4. `setupBidirectionalForwarding()` handles all data transfer:
- `onClientData`: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
- `onServerData`: `bytesSent += chunk.length` + `recordBytes(0, chunk.length)`
**Result**: ✅ Each byte counted exactly once
### 2. TLS Passthrough Connection
```
Client (TLS) → SmartProxy → Target Server (TLS)
```
1. Connection waits for initial data to detect TLS
2. TLS handshake detected, SNI extracted
3. Route matched, `setupDirectConnection()` called
4. Initial chunk stored in `pendingData` (NOT counted yet)
5. On target connect, `pendingData` written to target (still not counted)
6. `setupBidirectionalForwarding()` counts ALL bytes including initial chunk
**Result**: ✅ Each byte counted exactly once
### 3. TLS Termination via HttpProxy
```
Client (TLS) → SmartProxy → HttpProxy (localhost) → Target Server
```
1. TLS connection detected with `tls.mode = "terminate"`
2. `forwardToHttpProxy()` called:
- Initial chunk: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
3. Proxy connection created to HttpProxy on localhost
4. `setupBidirectionalForwarding()` handles subsequent data
**Result**: ✅ Each byte counted exactly once
### 4. HTTP Connection via HttpProxy
```
Client (HTTP) → SmartProxy → HttpProxy (localhost) → Target Server
```
1. Connection on configured HTTP port (`useHttpProxy` ports)
2. Same flow as TLS termination
3. All byte counting identical to TLS termination
**Result**: ✅ Each byte counted exactly once
### 5. NFTables Forwarding
```
Client → [Kernel NFTables] → Target Server
```
1. Connection detected, route matched with `forwardingEngine: 'nftables'`
2. Connection marked as `usingNetworkProxy = true`
3. NO application-level forwarding (kernel handles packet routing)
4. NO byte counting in application layer
**Result**: ✅ No counting (correct - kernel handles everything)
## Special Cases
### PROXY Protocol
- PROXY protocol headers sent to backend servers are NOT counted in client metrics
- Only actual client data is counted
- **Correct behavior**: Protocol overhead is not client data
### TLS Alerts
- TLS alerts (e.g., for missing SNI) are counted as sent bytes
- **Correct behavior**: Alerts are actual data sent to the client
### Initial Chunks
- **Direct connections**: Stored in `pendingData`, counted when forwarded
- **HttpProxy connections**: Counted immediately upon receipt
- **Both approaches**: Count each byte exactly once
## Verification Methodology
1. **Code Analysis**: Searched for all instances of:
- `bytesReceived +=` and `bytesSent +=`
- `recordBytes()` calls
- Data forwarding implementations
2. **Flow Tracing**: Followed data path for each connection type from entry to exit
3. **Handler Review**: Examined all forwarding handlers to ensure no additional counting
## Findings
### ✅ No Double Counting Detected
- Each byte is counted exactly once in the direction it flows
- Connection records and metrics are updated consistently
- No overlapping or duplicate counting logic found
### Areas of Excellence
1. **Centralized Counting**: All byte counting happens in just two files
2. **Consistent Pattern**: Uses `setupBidirectionalForwarding()` with callbacks
3. **Clear Separation**: Forwarding handlers don't interfere with proxy metrics
## Recommendations
1. **Debug Logging**: Add optional debug logging to verify byte counts in production:
```typescript
if (settings.debugByteCount) {
logger.log('debug', `Bytes counted: ${connectionId} +${bytes} (total: ${record.bytesReceived})`);
}
```
2. **Unit Tests**: Create specific tests to ensure byte counting accuracy:
- Test initial chunk handling
- Test PROXY protocol overhead exclusion
- Test HttpProxy forwarding accuracy
3. **Protocol Overhead Tracking**: Consider separately tracking:
- PROXY protocol headers
- TLS handshake bytes
- HTTP headers vs body
4. **NFTables Documentation**: Clearly document that NFTables-forwarded connections are not included in application metrics
## Conclusion
SmartProxy's byte counting implementation is **robust and accurate**. The design ensures that each byte is counted exactly once, with clear separation between connection tracking and metrics collection. No remediation is required.

View File

@@ -2,6 +2,42 @@
## Byte Tracking and Metrics ## Byte Tracking and Metrics
### Throughput Drift Issue (Fixed)
**Problem**: Throughput numbers were gradually increasing over time for long-lived connections.
**Root Cause**: The `byRoute()` and `byIP()` methods were dividing cumulative total bytes (since connection start) by the window duration, causing rates to appear higher as connections aged:
- Hour 1: 1GB total / 60s = 17 MB/s ✓
- Hour 2: 2GB total / 60s = 34 MB/s ✗ (appears doubled!)
- Hour 3: 3GB total / 60s = 50 MB/s ✗ (keeps rising!)
**Solution**: Implemented dedicated ThroughputTracker instances for each route and IP address:
- Each route and IP gets its own throughput tracker with per-second sampling
- Samples are taken every second and stored in a circular buffer
- Rate calculations use actual samples within the requested window
- Default window is now 1 second for real-time accuracy
### What Gets Counted (Network Interface Throughput)
The byte tracking is designed to match network interface throughput (what Unifi/network monitoring tools show):
**Counted bytes include:**
- All application data
- TLS handshakes and protocol overhead
- TLS record headers and encryption padding
- HTTP headers and protocol data
- WebSocket frames and protocol overhead
- TLS alerts sent to clients
**NOT counted:**
- PROXY protocol headers (sent to backend, not client)
- TCP/IP headers (handled by OS, not visible at application layer)
**Byte direction:**
- `bytesReceived`: All bytes received FROM the client on the incoming connection
- `bytesSent`: All bytes sent TO the client on the incoming connection
- Backend connections are separate and not mixed with client metrics
### Double Counting Issue (Fixed) ### Double Counting Issue (Fixed)
**Problem**: Initial data chunks were being counted twice in the byte tracking: **Problem**: Initial data chunks were being counted twice in the byte tracking:
@@ -18,12 +54,64 @@
### Metrics Architecture ### Metrics Architecture
The metrics system has three layers: The metrics system has multiple layers:
1. **Connection Records** (`record.bytesReceived/bytesSent`): Track total bytes per connection 1. **Connection Records** (`record.bytesReceived/bytesSent`): Track total bytes per connection
2. **ThroughputTracker**: Accumulates bytes between samples for rate calculations (bytes/second) 2. **Global ThroughputTracker**: Accumulates bytes between samples for overall rate calculations
3. **connectionByteTrackers**: Track bytes per connection with timestamps for per-route/IP metrics 3. **Per-Route ThroughputTrackers**: Dedicated tracker for each route with per-second sampling
4. **Per-IP ThroughputTrackers**: Dedicated tracker for each IP with per-second sampling
5. **connectionByteTrackers**: Track cumulative bytes and metadata for active connections
Total byte counts come from connection records only, preventing double counting. Key features:
- All throughput trackers sample every second (1Hz)
- Each tracker maintains a circular buffer of samples (default: 1 hour retention)
- Rate calculations are accurate for any requested window (default: 1 second)
- All byte counting happens exactly once at the data flow point
- Unused route/IP trackers are automatically cleaned up when connections close
### Understanding "High" Byte Counts
If byte counts seem high compared to actual application data, remember:
- TLS handshakes can be 1-5KB depending on cipher suites and certificates
- Each TLS record has 5 bytes of header overhead
- TLS encryption adds 16-48 bytes of padding/MAC per record
- HTTP/2 has additional framing overhead
- WebSocket has frame headers (2-14 bytes per message)
This overhead is real network traffic and should be counted for accurate throughput metrics.
### Byte Counting Paths
There are two mutually exclusive paths for connections:
1. **Direct forwarding** (route-connection-handler.ts):
- Used for TCP passthrough, TLS passthrough, and direct connections
- Bytes counted in `setupBidirectionalForwarding` callbacks
- Initial chunk NOT counted separately (flows through bidirectional forwarding)
2. **HttpProxy forwarding** (http-proxy-bridge.ts):
- Used for TLS termination (terminate, terminate-and-reencrypt)
- Initial chunk counted when written to proxy
- All subsequent bytes counted in `setupBidirectionalForwarding` callbacks
- This is the ONLY counting point for these connections
### Byte Counting Audit (2025-01-06)
A comprehensive audit was performed to verify byte counting accuracy:
**Audit Results:**
- ✅ No double counting detected in any connection flow
- ✅ Each byte counted exactly once in each direction
- ✅ Connection records and metrics updated consistently
- ✅ PROXY protocol headers correctly excluded from client metrics
- ✅ NFTables forwarded connections correctly not counted (kernel handles)
**Key Implementation Points:**
- All byte counting happens in only 2 files: `route-connection-handler.ts` and `http-proxy-bridge.ts`
- Both use the same pattern: increment `record.bytesReceived/Sent` AND call `metricsCollector.recordBytes()`
- Initial chunks handled correctly: stored but not counted until forwarded
- TLS alerts counted as sent bytes (correct - they are sent to client)
For full audit details, see `readme.byte-counting-audit.md`
## Connection Cleanup ## Connection Cleanup
@@ -55,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

2929
readme.md

File diff suppressed because it is too large Load Diff

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',
target: { host: 'backend', port: 8080 },
tls: { mode: 'terminate' }
} }
},
class ThroughputTracker { {
private samples: IThroughputSample[] = []; match: { domains: ['api.example.com'], ports: [443] },
private readonly MAX_SAMPLES = 3600; // 1 hour at 1 sample/second action: {
private lastSampleTime: number = 0; type: 'forward',
private accumulatedBytesIn: number = 0; target: { host: 'backend', port: 8081 },
private accumulatedBytesOut: number = 0; tls: { mode: 'passthrough' }
// 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 ### After (Enhanced)
public getRate(windowSeconds: number): { bytesInPerSec: number; bytesOutPerSec: number } { ```typescript
const now = Date.now(); // Single route with multiple targets
const windowStart = now - (windowSeconds * 1000); {
match: { domains: ['api.example.com'], ports: [80, 443] },
const relevantSamples = this.samples.filter(s => s.timestamp > windowStart); action: {
type: 'forward',
if (relevantSamples.length === 0) { targets: [
return { bytesInPerSec: 0, bytesOutPerSec: 0 }; {
match: { ports: [80] },
host: 'backend',
port: 8080,
tls: { mode: 'terminate' }
},
{
match: { ports: [443] },
host: 'backend',
port: 8081,
tls: { mode: 'passthrough' }
} }
]
const totalBytesIn = relevantSamples.reduce((sum, s) => sum + s.bytesIn, 0);
const totalBytesOut = relevantSamples.reduce((sum, s) => sum + s.bytesOut, 0);
const actualWindow = (now - relevantSamples[0].timestamp) / 1000;
return {
bytesInPerSec: Math.round(totalBytesIn / actualWindow),
bytesOutPerSec: Math.round(totalBytesOut / actualWindow)
};
} }
} }
``` ```
### 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 }>;
};
// Throughput metrics (bytes per second)
throughput: {
instant(): { in: number; out: number }; // Last 1 second
recent(): { in: number; out: number }; // Last 10 seconds
average(): { in: number; out: number }; // Last 60 seconds
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, enabled: true,
sampleIntervalMs: 1000, rewritePath: '/' // Strip /ws prefix
enableDetailedTracking: true }
},
{
// Default target (no match property)
host: 'web-backend',
port: 8080
}
]
}
} }
});
// 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.

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();

131
test/test.detection.ts Normal file
View File

@@ -0,0 +1,131 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartproxy from '../ts/index.js';
tap.test('Protocol Detection - TLS Detection', async () => {
// Test TLS handshake detection
const tlsHandshake = Buffer.from([
0x16, // Handshake record type
0x03, 0x01, // TLS 1.0
0x00, 0x05, // Length: 5 bytes
0x01, // ClientHello
0x00, 0x00, 0x01, 0x00 // Handshake length and data
]);
const detector = new smartproxy.detection.TlsDetector();
expect(detector.canHandle(tlsHandshake)).toEqual(true);
const result = detector.detect(tlsHandshake);
expect(result).toBeDefined();
expect(result?.protocol).toEqual('tls');
expect(result?.connectionInfo.tlsVersion).toEqual('TLSv1.0');
});
tap.test('Protocol Detection - HTTP Detection', async () => {
// Test HTTP request detection
const httpRequest = Buffer.from(
'GET /test HTTP/1.1\r\n' +
'Host: example.com\r\n' +
'User-Agent: TestClient/1.0\r\n' +
'\r\n'
);
const detector = new smartproxy.detection.HttpDetector();
expect(detector.canHandle(httpRequest)).toEqual(true);
const result = detector.detect(httpRequest);
expect(result).toBeDefined();
expect(result?.protocol).toEqual('http');
expect(result?.connectionInfo.method).toEqual('GET');
expect(result?.connectionInfo.path).toEqual('/test');
expect(result?.connectionInfo.domain).toEqual('example.com');
});
tap.test('Protocol Detection - Main Detector TLS', async () => {
const tlsHandshake = Buffer.from([
0x16, // Handshake record type
0x03, 0x03, // TLS 1.2
0x00, 0x05, // Length: 5 bytes
0x01, // ClientHello
0x00, 0x00, 0x01, 0x00 // Handshake length and data
]);
const result = await smartproxy.detection.ProtocolDetector.detect(tlsHandshake);
expect(result.protocol).toEqual('tls');
expect(result.connectionInfo.tlsVersion).toEqual('TLSv1.2');
});
tap.test('Protocol Detection - Main Detector HTTP', async () => {
const httpRequest = Buffer.from(
'POST /api/test HTTP/1.1\r\n' +
'Host: api.example.com\r\n' +
'Content-Type: application/json\r\n' +
'Content-Length: 2\r\n' +
'\r\n' +
'{}'
);
const result = await smartproxy.detection.ProtocolDetector.detect(httpRequest);
expect(result.protocol).toEqual('http');
expect(result.connectionInfo.method).toEqual('POST');
expect(result.connectionInfo.path).toEqual('/api/test');
expect(result.connectionInfo.domain).toEqual('api.example.com');
});
tap.test('Protocol Detection - Unknown Protocol', async () => {
const unknownData = Buffer.from('UNKNOWN PROTOCOL DATA\r\n');
const result = await smartproxy.detection.ProtocolDetector.detect(unknownData);
expect(result.protocol).toEqual('unknown');
expect(result.isComplete).toEqual(true);
});
tap.test('Protocol Detection - Fragmented HTTP', async () => {
const connectionId = 'test-connection-1';
// First fragment
const fragment1 = Buffer.from('GET /test HT');
let result = await smartproxy.detection.ProtocolDetector.detectWithConnectionTracking(
fragment1,
connectionId
);
expect(result.protocol).toEqual('http');
expect(result.isComplete).toEqual(false);
// Second fragment
const fragment2 = Buffer.from('TP/1.1\r\nHost: example.com\r\n\r\n');
result = await smartproxy.detection.ProtocolDetector.detectWithConnectionTracking(
fragment2,
connectionId
);
expect(result.protocol).toEqual('http');
expect(result.isComplete).toEqual(true);
expect(result.connectionInfo.method).toEqual('GET');
expect(result.connectionInfo.path).toEqual('/test');
expect(result.connectionInfo.domain).toEqual('example.com');
});
tap.test('Protocol Detection - HTTP Methods', async () => {
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
for (const method of methods) {
const request = Buffer.from(
`${method} /test HTTP/1.1\r\n` +
'Host: example.com\r\n' +
'\r\n'
);
const detector = new smartproxy.detection.HttpDetector();
const result = detector.detect(request);
expect(result?.connectionInfo.method).toEqual(method);
}
});
tap.test('Protocol Detection - Invalid Data', async () => {
// Binary data that's not a valid protocol
const binaryData = Buffer.from([0xFF, 0xFE, 0xFD, 0xFC, 0xFB]);
const result = await smartproxy.detection.ProtocolDetector.detect(binaryData);
expect(result.protocol).toEqual('unknown');
});
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

@@ -1,9 +1,6 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js'; import * as plugins from '../ts/plugins.js';
import type { IForwardConfig, TForwardingType } from '../ts/forwarding/config/forwarding-types.js';
// First, import the components directly to avoid issues with compiled modules
import { ForwardingHandlerFactory } from '../ts/forwarding/factory/forwarding-factory.js';
// Import route-based helpers // Import route-based helpers
import { import {
createHttpRoute, createHttpRoute,
@@ -39,7 +36,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

@@ -1,53 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
// First, import the components directly to avoid issues with compiled modules
import { ForwardingHandlerFactory } from '../ts/forwarding/factory/forwarding-factory.js';
// Import route-based helpers from the correct location
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../ts/proxies/smart-proxy/utils/route-patterns.js';
// Create helper functions for building forwarding configs
const helpers = {
httpOnly: () => ({ type: 'http-only' as const }),
tlsTerminateToHttp: () => ({ type: 'https-terminate-to-http' as const }),
tlsTerminateToHttps: () => ({ type: 'https-terminate-to-https' as const }),
httpsPassthrough: () => ({ type: 'https-passthrough' as const })
};
tap.test('ForwardingHandlerFactory - apply defaults based on type', async () => {
// HTTP-only defaults
const httpConfig = {
type: 'http-only' as const,
target: { host: 'localhost', port: 3000 }
};
const httpWithDefaults = ForwardingHandlerFactory['applyDefaults'](httpConfig);
expect(httpWithDefaults.port).toEqual(80);
expect(httpWithDefaults.socket).toEqual('/tmp/forwarding-http-only-80.sock');
// HTTPS passthrough defaults
const httpsPassthroughConfig = {
type: 'https-passthrough' as const,
target: { host: 'localhost', port: 443 }
};
const httpsPassthroughWithDefaults = ForwardingHandlerFactory['applyDefaults'](httpsPassthroughConfig);
expect(httpsPassthroughWithDefaults.port).toEqual(443);
expect(httpsPassthroughWithDefaults.socket).toEqual('/tmp/forwarding-https-passthrough-443.sock');
});
tap.test('ForwardingHandlerFactory - factory function for handlers', async () => {
// @todo Implement unit tests for ForwardingHandlerFactory
// These tests would need proper mocking of the handlers
});
export default tap.start();

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

@@ -47,7 +47,7 @@ import {
addRateLimiting, addRateLimiting,
addBasicAuth, addBasicAuth,
addJwtAuth addJwtAuth
} from '../ts/proxies/smart-proxy/utils/route-patterns.js'; } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type { import type {
IRouteConfig, IRouteConfig,
@@ -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

@@ -1,161 +1,44 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { ProxyProtocolParser as ProtocolParser, type IProxyInfo, type IProxyParseResult } from '../../protocols/proxy/index.js';
/** // Re-export types from protocols for backward compatibility
* Interface representing parsed PROXY protocol information export type { IProxyInfo, IProxyParseResult } from '../../protocols/proxy/index.js';
*/
export interface IProxyInfo {
protocol: 'TCP4' | 'TCP6' | 'UNKNOWN';
sourceIP: string;
sourcePort: number;
destinationIP: string;
destinationPort: number;
}
/**
* Interface for parse result including remaining data
*/
export interface IProxyParseResult {
proxyInfo: IProxyInfo | null;
remainingData: Buffer;
}
/** /**
* Parser for PROXY protocol v1 (text format) * Parser for PROXY protocol v1 (text format)
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt * Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
*
* This class now delegates to the protocol parser but adds
* smartproxy-specific features like socket reading and logging
*/ */
export class ProxyProtocolParser { export class ProxyProtocolParser {
static readonly PROXY_V1_SIGNATURE = 'PROXY '; static readonly PROXY_V1_SIGNATURE = ProtocolParser.PROXY_V1_SIGNATURE;
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header static readonly MAX_HEADER_LENGTH = ProtocolParser.MAX_HEADER_LENGTH;
static readonly HEADER_TERMINATOR = '\r\n'; static readonly HEADER_TERMINATOR = ProtocolParser.HEADER_TERMINATOR;
/** /**
* Parse PROXY protocol v1 header from buffer * Parse PROXY protocol v1 header from buffer
* Returns proxy info and remaining data after header * Returns proxy info and remaining data after header
*/ */
static parse(data: Buffer): IProxyParseResult { static parse(data: Buffer): IProxyParseResult {
// Check if buffer starts with PROXY signature // Delegate to protocol parser
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) { return ProtocolParser.parse(data);
return {
proxyInfo: null,
remainingData: data
};
}
// Find header terminator
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
if (headerEndIndex === -1) {
// Header incomplete, need more data
if (data.length > this.MAX_HEADER_LENGTH) {
// Header too long, invalid
throw new Error('PROXY protocol header exceeds maximum length');
}
return {
proxyInfo: null,
remainingData: data
};
}
// Extract header line
const headerLine = data.toString('ascii', 0, headerEndIndex);
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
// Parse header
const parts = headerLine.split(' ');
if (parts.length < 2) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [signature, protocol] = parts;
// Validate protocol
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
throw new Error(`Invalid PROXY protocol: ${protocol}`);
}
// For UNKNOWN protocol, ignore addresses
if (protocol === 'UNKNOWN') {
return {
proxyInfo: {
protocol: 'UNKNOWN',
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
},
remainingData
};
}
// For TCP4/TCP6, we need all 6 parts
if (parts.length !== 6) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
// Validate and parse ports
const sourcePort = parseInt(srcPort, 10);
const destinationPort = parseInt(dstPort, 10);
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
throw new Error(`Invalid source port: ${srcPort}`);
}
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
throw new Error(`Invalid destination port: ${dstPort}`);
}
// Validate IP addresses
const protocolType = protocol as 'TCP4' | 'TCP6' | 'UNKNOWN';
if (!this.isValidIP(srcIP, protocolType)) {
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
}
if (!this.isValidIP(dstIP, protocolType)) {
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
}
return {
proxyInfo: {
protocol: protocol as 'TCP4' | 'TCP6',
sourceIP: srcIP,
sourcePort,
destinationIP: dstIP,
destinationPort
},
remainingData
};
} }
/** /**
* Generate PROXY protocol v1 header * Generate PROXY protocol v1 header
*/ */
static generate(info: IProxyInfo): Buffer { static generate(info: IProxyInfo): Buffer {
if (info.protocol === 'UNKNOWN') { // Delegate to protocol parser
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii'); return ProtocolParser.generate(info);
}
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
if (header.length > this.MAX_HEADER_LENGTH) {
throw new Error('Generated PROXY protocol header exceeds maximum length');
}
return Buffer.from(header, 'ascii');
} }
/** /**
* Validate IP address format * Validate IP address format
*/ */
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean { private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
if (protocol === 'TCP4') { return ProtocolParser.isValidIP(ip, protocol);
return plugins.net.isIPv4(ip);
} else if (protocol === 'TCP6') {
return plugins.net.isIPv6(ip);
}
return false;
} }
/** /**

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

@@ -1,12 +1,13 @@
/** /**
* WebSocket utility functions * WebSocket utility functions
*
* This module provides smartproxy-specific WebSocket utilities
* and re-exports protocol utilities from the protocols module
*/ */
/** // Import and re-export from protocols
* Type for WebSocket RawData that can be different types in different environments import { getMessageSize as protocolGetMessageSize, toBuffer as protocolToBuffer } from '../../protocols/websocket/index.js';
* This matches the ws library's type definition export type { RawData } from '../../protocols/websocket/index.js';
*/
export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
/** /**
* Get the length of a WebSocket message regardless of its type * Get the length of a WebSocket message regardless of its type
@@ -15,35 +16,9 @@ export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
* @param data - The data message from WebSocket (could be any RawData type) * @param data - The data message from WebSocket (could be any RawData type)
* @returns The length of the data in bytes * @returns The length of the data in bytes
*/ */
export function getMessageSize(data: RawData): number { export function getMessageSize(data: import('../../protocols/websocket/index.js').RawData): number {
if (typeof data === 'string') { // Delegate to protocol implementation
// For string data, get the byte length return protocolGetMessageSize(data);
return Buffer.from(data, 'utf8').length;
} else if (data instanceof Buffer) {
// For Node.js Buffer
return data.length;
} else if (data instanceof ArrayBuffer) {
// For ArrayBuffer
return data.byteLength;
} else if (Array.isArray(data)) {
// For array of buffers, sum their lengths
return data.reduce((sum, chunk) => {
if (chunk instanceof Buffer) {
return sum + chunk.length;
} else if (chunk instanceof ArrayBuffer) {
return sum + chunk.byteLength;
}
return sum;
}, 0);
} else {
// For other types, try to determine the size or return 0
try {
return Buffer.from(data).length;
} catch (e) {
console.warn('Could not determine message size', e);
return 0;
}
}
} }
/** /**
@@ -52,30 +27,7 @@ export function getMessageSize(data: RawData): number {
* @param data - The data message from WebSocket (could be any RawData type) * @param data - The data message from WebSocket (could be any RawData type)
* @returns A Buffer containing the data * @returns A Buffer containing the data
*/ */
export function toBuffer(data: RawData): Buffer { export function toBuffer(data: import('../../protocols/websocket/index.js').RawData): Buffer {
if (typeof data === 'string') { // Delegate to protocol implementation
return Buffer.from(data, 'utf8'); return protocolToBuffer(data);
} else if (data instanceof Buffer) {
return data;
} else if (data instanceof ArrayBuffer) {
return Buffer.from(data);
} else if (Array.isArray(data)) {
// For array of buffers, concatenate them
return Buffer.concat(data.map(chunk => {
if (chunk instanceof Buffer) {
return chunk;
} else if (chunk instanceof ArrayBuffer) {
return Buffer.from(chunk);
}
return Buffer.from(chunk);
}));
} else {
// For other types, try to convert to Buffer or return empty Buffer
try {
return Buffer.from(data);
} catch (e) {
console.warn('Could not convert message to Buffer', e);
return Buffer.alloc(0);
}
}
} }

View File

@@ -0,0 +1,281 @@
/**
* HTTP protocol detector
*/
import type { IProtocolDetector } from '../models/interfaces.js';
import type { IDetectionResult, IDetectionOptions, IConnectionInfo, THttpMethod } from '../models/detection-types.js';
import { extractLine, isPrintableAscii, BufferAccumulator } from '../utils/buffer-utils.js';
import { parseHttpRequestLine, parseHttpHeaders, extractDomainFromHost, isHttpMethod } from '../utils/parser-utils.js';
/**
* HTTP detector implementation
*/
export class HttpDetector implements IProtocolDetector {
/**
* Minimum bytes needed to identify HTTP method
*/
private static readonly MIN_HTTP_METHOD_SIZE = 3; // GET
/**
* Maximum reasonable HTTP header size
*/
private static readonly MAX_HEADER_SIZE = 8192;
/**
* Fragment tracking for incomplete headers
*/
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
/**
* Detect HTTP protocol from buffer
*/
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
// Check if buffer is too small
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
return null;
}
// Quick check: first bytes should be printable ASCII
if (!isPrintableAscii(buffer, Math.min(20, buffer.length))) {
return null;
}
// Try to extract the first line
const firstLineResult = extractLine(buffer, 0);
if (!firstLineResult) {
// No complete line yet
return {
protocol: 'http',
connectionInfo: { protocol: 'http' },
isComplete: false,
bytesNeeded: buffer.length + 100 // Estimate
};
}
// Parse the request line
const requestLine = parseHttpRequestLine(firstLineResult.line);
if (!requestLine) {
// Not a valid HTTP request line
return null;
}
// Initialize connection info
const connectionInfo: IConnectionInfo = {
protocol: 'http',
method: requestLine.method,
path: requestLine.path,
httpVersion: requestLine.version
};
// Check if we want to extract headers
if (options?.extractFullHeaders !== false) {
// Look for the end of headers (double CRLF)
const headerEndSequence = Buffer.from('\r\n\r\n');
const headerEndIndex = buffer.indexOf(headerEndSequence);
if (headerEndIndex === -1) {
// Headers not complete yet
const maxSize = options?.maxBufferSize || HttpDetector.MAX_HEADER_SIZE;
if (buffer.length >= maxSize) {
// Headers too large, reject
return null;
}
return {
protocol: 'http',
connectionInfo,
isComplete: false,
bytesNeeded: buffer.length + 200 // Estimate
};
}
// Extract all header lines
const headerLines: string[] = [];
let currentOffset = firstLineResult.nextOffset;
while (currentOffset < headerEndIndex) {
const lineResult = extractLine(buffer, currentOffset);
if (!lineResult) {
break;
}
if (lineResult.line.length === 0) {
// Empty line marks end of headers
break;
}
headerLines.push(lineResult.line);
currentOffset = lineResult.nextOffset;
}
// Parse headers
const headers = parseHttpHeaders(headerLines);
connectionInfo.headers = headers;
// Extract domain from Host header
const hostHeader = headers['host'];
if (hostHeader) {
connectionInfo.domain = extractDomainFromHost(hostHeader);
}
// Calculate remaining buffer
const bodyStartIndex = headerEndIndex + 4; // After \r\n\r\n
const remainingBuffer = buffer.length > bodyStartIndex
? buffer.slice(bodyStartIndex)
: undefined;
return {
protocol: 'http',
connectionInfo,
remainingBuffer,
isComplete: true
};
} else {
// Just extract Host header for domain
let currentOffset = firstLineResult.nextOffset;
const maxLines = 50; // Reasonable limit
for (let i = 0; i < maxLines && currentOffset < buffer.length; i++) {
const lineResult = extractLine(buffer, currentOffset);
if (!lineResult) {
// Need more data
return {
protocol: 'http',
connectionInfo,
isComplete: false,
bytesNeeded: buffer.length + 50
};
}
if (lineResult.line.length === 0) {
// End of headers
break;
}
// Quick check for Host header
if (lineResult.line.toLowerCase().startsWith('host:')) {
const colonIndex = lineResult.line.indexOf(':');
const hostValue = lineResult.line.slice(colonIndex + 1).trim();
connectionInfo.domain = extractDomainFromHost(hostValue);
// If we only needed the domain, we can return early
return {
protocol: 'http',
connectionInfo,
isComplete: true
};
}
currentOffset = lineResult.nextOffset;
}
// If we reach here, no Host header found yet
return {
protocol: 'http',
connectionInfo,
isComplete: false,
bytesNeeded: buffer.length + 100
};
}
}
/**
* Check if buffer can be handled by this detector
*/
canHandle(buffer: Buffer): boolean {
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
return false;
}
// Check if first bytes could be an HTTP method
const firstWord = buffer.slice(0, Math.min(10, buffer.length)).toString('ascii').split(' ')[0];
return isHttpMethod(firstWord);
}
/**
* Get minimum bytes needed for detection
*/
getMinimumBytes(): number {
return HttpDetector.MIN_HTTP_METHOD_SIZE;
}
/**
* Quick check if buffer starts with HTTP method
*/
static quickCheck(buffer: Buffer): boolean {
if (buffer.length < 3) {
return false;
}
// Check common HTTP methods
const start = buffer.slice(0, 7).toString('ascii');
return start.startsWith('GET ') ||
start.startsWith('POST ') ||
start.startsWith('PUT ') ||
start.startsWith('DELETE ') ||
start.startsWith('HEAD ') ||
start.startsWith('OPTIONS') ||
start.startsWith('PATCH ') ||
start.startsWith('CONNECT') ||
start.startsWith('TRACE ');
}
/**
* Handle fragmented HTTP detection with connection tracking
*/
static detectWithFragments(
buffer: Buffer,
connectionId: string,
options?: IDetectionOptions
): IDetectionResult | null {
const detector = new HttpDetector();
// Try direct detection first
const directResult = detector.detect(buffer, options);
if (directResult && directResult.isComplete) {
// Clean up any tracked fragments for this connection
this.fragmentedBuffers.delete(connectionId);
return directResult;
}
// Handle fragmentation
let accumulator = this.fragmentedBuffers.get(connectionId);
if (!accumulator) {
accumulator = new BufferAccumulator();
this.fragmentedBuffers.set(connectionId, accumulator);
}
accumulator.append(buffer);
const fullBuffer = accumulator.getBuffer();
// Check size limit
const maxSize = options?.maxBufferSize || this.MAX_HEADER_SIZE;
if (fullBuffer.length > maxSize) {
// Too large, clean up and reject
this.fragmentedBuffers.delete(connectionId);
return null;
}
// Try detection on accumulated buffer
const result = detector.detect(fullBuffer, options);
if (result && result.isComplete) {
// Success - clean up
this.fragmentedBuffers.delete(connectionId);
return result;
}
return result;
}
/**
* Clean up old fragment buffers
*/
static cleanupFragments(maxAge: number = 5000): void {
// TODO: Add timestamp tracking to BufferAccumulator for cleanup
// For now, just clear if too many connections
if (this.fragmentedBuffers.size > 1000) {
this.fragmentedBuffers.clear();
}
}
}

View File

@@ -0,0 +1,259 @@
/**
* TLS protocol detector
*/
// TLS detector doesn't need plugins imports
import type { IProtocolDetector } from '../models/interfaces.js';
import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from '../models/detection-types.js';
import { readUInt16BE, readUInt24BE, BufferAccumulator } from '../utils/buffer-utils.js';
import { tlsVersionToString } from '../utils/parser-utils.js';
// Import from protocols
import { TlsRecordType, TlsHandshakeType, TlsExtensionType } from '../../protocols/tls/index.js';
// Import TLS utilities for SNI extraction from protocols
import { SniExtraction } from '../../protocols/tls/sni/sni-extraction.js';
import { ClientHelloParser } from '../../protocols/tls/sni/client-hello-parser.js';
/**
* TLS detector implementation
*/
export class TlsDetector implements IProtocolDetector {
/**
* Minimum bytes needed to identify TLS (record header)
*/
private static readonly MIN_TLS_HEADER_SIZE = 5;
/**
* Fragment tracking for incomplete handshakes
*/
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
/**
* Detect TLS protocol from buffer
*/
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
// Check if buffer is too small
if (buffer.length < TlsDetector.MIN_TLS_HEADER_SIZE) {
return null;
}
// Check if this is a TLS record
if (!this.isTlsRecord(buffer)) {
return null;
}
// Extract basic TLS info
const recordType = buffer[0];
const tlsMajor = buffer[1];
const tlsMinor = buffer[2];
const recordLength = readUInt16BE(buffer, 3);
// Initialize connection info
const connectionInfo: IConnectionInfo = {
protocol: 'tls',
tlsVersion: tlsVersionToString(tlsMajor, tlsMinor) || undefined
};
// If it's a handshake, try to extract more info
if (recordType === TlsRecordType.HANDSHAKE && buffer.length >= 6) {
const handshakeType = buffer[5];
// For ClientHello, extract SNI and other info
if (handshakeType === TlsHandshakeType.CLIENT_HELLO) {
// Check if we have the complete handshake
const totalRecordLength = recordLength + 5; // Including TLS header
if (buffer.length >= totalRecordLength) {
// Extract SNI using existing logic
const sni = SniExtraction.extractSNI(buffer);
if (sni) {
connectionInfo.domain = sni;
connectionInfo.sni = sni;
}
// Parse ClientHello for additional info
const parseResult = ClientHelloParser.parseClientHello(buffer);
if (parseResult.isValid) {
// Extract ALPN if present
const alpnExtension = parseResult.extensions.find(
ext => ext.type === TlsExtensionType.APPLICATION_LAYER_PROTOCOL_NEGOTIATION
);
if (alpnExtension) {
connectionInfo.alpn = this.parseAlpnExtension(alpnExtension.data);
}
// Store cipher suites if needed
if (parseResult.cipherSuites && options?.extractFullHeaders) {
connectionInfo.cipherSuites = this.parseCipherSuites(parseResult.cipherSuites);
}
}
// Return complete result
return {
protocol: 'tls',
connectionInfo,
remainingBuffer: buffer.length > totalRecordLength
? buffer.subarray(totalRecordLength)
: undefined,
isComplete: true
};
} else {
// Incomplete handshake
return {
protocol: 'tls',
connectionInfo,
isComplete: false,
bytesNeeded: totalRecordLength
};
}
}
}
// For other TLS record types, just return basic info
return {
protocol: 'tls',
connectionInfo,
isComplete: true,
remainingBuffer: buffer.length > recordLength + 5
? buffer.subarray(recordLength + 5)
: undefined
};
}
/**
* Check if buffer can be handled by this detector
*/
canHandle(buffer: Buffer): boolean {
return buffer.length >= TlsDetector.MIN_TLS_HEADER_SIZE &&
this.isTlsRecord(buffer);
}
/**
* Get minimum bytes needed for detection
*/
getMinimumBytes(): number {
return TlsDetector.MIN_TLS_HEADER_SIZE;
}
/**
* Check if buffer contains a valid TLS record
*/
private isTlsRecord(buffer: Buffer): boolean {
const recordType = buffer[0];
// Check for valid record type
const validTypes = [
TlsRecordType.CHANGE_CIPHER_SPEC,
TlsRecordType.ALERT,
TlsRecordType.HANDSHAKE,
TlsRecordType.APPLICATION_DATA,
TlsRecordType.HEARTBEAT
];
if (!validTypes.includes(recordType)) {
return false;
}
// Check TLS version bytes (should be 0x03 0x0X)
if (buffer[1] !== 0x03) {
return false;
}
// Check record length is reasonable
const recordLength = readUInt16BE(buffer, 3);
if (recordLength > 16384) { // Max TLS record size
return false;
}
return true;
}
/**
* Parse ALPN extension data
*/
private parseAlpnExtension(data: Buffer): string[] {
const protocols: string[] = [];
if (data.length < 2) {
return protocols;
}
const listLength = readUInt16BE(data, 0);
let offset = 2;
while (offset < Math.min(2 + listLength, data.length)) {
const protoLength = data[offset];
offset++;
if (offset + protoLength <= data.length) {
const protocol = data.subarray(offset, offset + protoLength).toString('ascii');
protocols.push(protocol);
offset += protoLength;
} else {
break;
}
}
return protocols;
}
/**
* Parse cipher suites
*/
private parseCipherSuites(data: Buffer): number[] {
const suites: number[] = [];
for (let i = 0; i + 1 < data.length; i += 2) {
const suite = readUInt16BE(data, i);
suites.push(suite);
}
return suites;
}
/**
* Handle fragmented TLS detection with connection tracking
*/
static detectWithFragments(
buffer: Buffer,
connectionId: string,
options?: IDetectionOptions
): IDetectionResult | null {
const detector = new TlsDetector();
// Try direct detection first
const directResult = detector.detect(buffer, options);
if (directResult && directResult.isComplete) {
// Clean up any tracked fragments for this connection
this.fragmentedBuffers.delete(connectionId);
return directResult;
}
// Handle fragmentation
let accumulator = this.fragmentedBuffers.get(connectionId);
if (!accumulator) {
accumulator = new BufferAccumulator();
this.fragmentedBuffers.set(connectionId, accumulator);
}
accumulator.append(buffer);
const fullBuffer = accumulator.getBuffer();
// Try detection on accumulated buffer
const result = detector.detect(fullBuffer, options);
if (result && result.isComplete) {
// Success - clean up
this.fragmentedBuffers.delete(connectionId);
return result;
}
// Check timeout
if (options?.timeout) {
// TODO: Implement timeout handling
}
return result;
}
}

22
ts/detection/index.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Centralized Protocol Detection Module
*
* This module provides unified protocol detection capabilities for
* both TLS and HTTP protocols, extracting connection information
* without consuming the data stream.
*/
// Main detector
export * from './protocol-detector.js';
// Models
export * from './models/detection-types.js';
export * from './models/interfaces.js';
// Individual detectors
export * from './detectors/tls-detector.js';
export * from './detectors/http-detector.js';
// Utilities
export * from './utils/buffer-utils.js';
export * from './utils/parser-utils.js';

View File

@@ -0,0 +1,102 @@
/**
* Type definitions for protocol detection
*/
/**
* Supported protocol types that can be detected
*/
export type TProtocolType = 'tls' | 'http' | 'unknown';
/**
* HTTP method types
*/
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
/**
* TLS version identifiers
*/
export type TTlsVersion = 'SSLv3' | 'TLSv1.0' | 'TLSv1.1' | 'TLSv1.2' | 'TLSv1.3';
/**
* Connection information extracted from protocol detection
*/
export interface IConnectionInfo {
/**
* The detected protocol type
*/
protocol: TProtocolType;
/**
* Domain/hostname extracted from the connection
* - For TLS: from SNI extension
* - For HTTP: from Host header
*/
domain?: string;
/**
* HTTP-specific fields
*/
method?: THttpMethod;
path?: string;
httpVersion?: string;
headers?: Record<string, string>;
/**
* TLS-specific fields
*/
tlsVersion?: TTlsVersion;
sni?: string;
alpn?: string[];
cipherSuites?: number[];
}
/**
* Result of protocol detection
*/
export interface IDetectionResult {
/**
* The detected protocol type
*/
protocol: TProtocolType;
/**
* Extracted connection information
*/
connectionInfo: IConnectionInfo;
/**
* Any remaining buffer data after detection headers
* This can be used to continue processing the stream
*/
remainingBuffer?: Buffer;
/**
* Whether the detection is complete or needs more data
*/
isComplete: boolean;
/**
* Minimum bytes needed for complete detection (if incomplete)
*/
bytesNeeded?: number;
}
/**
* Options for protocol detection
*/
export interface IDetectionOptions {
/**
* Maximum bytes to buffer for detection (default: 8192)
*/
maxBufferSize?: number;
/**
* Timeout for detection in milliseconds (default: 5000)
*/
timeout?: number;
/**
* Whether to extract full headers or just essential info
*/
extractFullHeaders?: boolean;
}

View File

@@ -0,0 +1,115 @@
/**
* Interface definitions for protocol detection components
*/
import type { IDetectionResult, IDetectionOptions } from './detection-types.js';
/**
* Interface for protocol detectors
*/
export interface IProtocolDetector {
/**
* Detect protocol from buffer data
* @param buffer The buffer to analyze
* @param options Detection options
* @returns Detection result or null if protocol cannot be determined
*/
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null;
/**
* Check if buffer potentially contains this protocol
* @param buffer The buffer to check
* @returns True if buffer might contain this protocol
*/
canHandle(buffer: Buffer): boolean;
/**
* Get the minimum bytes needed for detection
*/
getMinimumBytes(): number;
}
/**
* Interface for connection tracking during fragmented detection
*/
export interface IConnectionTracker {
/**
* Connection identifier
*/
id: string;
/**
* Accumulated buffer data
*/
buffer: Buffer;
/**
* Timestamp of first data
*/
startTime: number;
/**
* Current detection state
*/
state: 'detecting' | 'complete' | 'failed';
/**
* Partial detection result (if any)
*/
partialResult?: Partial<IDetectionResult>;
}
/**
* Interface for buffer accumulator (handles fragmented data)
*/
export interface IBufferAccumulator {
/**
* Add data to accumulator
*/
append(data: Buffer): void;
/**
* Get accumulated buffer
*/
getBuffer(): Buffer;
/**
* Get buffer length
*/
length(): number;
/**
* Clear accumulated data
*/
clear(): void;
/**
* Check if accumulator has enough data
*/
hasMinimumBytes(minBytes: number): boolean;
}
/**
* Detection events
*/
export interface IDetectionEvents {
/**
* Emitted when protocol is successfully detected
*/
detected: (result: IDetectionResult) => void;
/**
* Emitted when detection fails
*/
failed: (error: Error) => void;
/**
* Emitted when detection times out
*/
timeout: () => void;
/**
* Emitted when more data is needed
*/
needMoreData: (bytesNeeded: number) => void;
}

View File

@@ -0,0 +1,222 @@
/**
* Main protocol detector that orchestrates detection across different protocols
*/
import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from './models/detection-types.js';
import { TlsDetector } from './detectors/tls-detector.js';
import { HttpDetector } from './detectors/http-detector.js';
/**
* Main protocol detector class
*/
export class ProtocolDetector {
/**
* Connection tracking for fragmented detection
*/
private static connectionTracking = new Map<string, {
startTime: number;
protocol?: 'tls' | 'http' | 'unknown';
}>();
/**
* Detect protocol from buffer data
*
* @param buffer The buffer to analyze
* @param options Detection options
* @returns Detection result with protocol information
*/
static async detect(
buffer: Buffer,
options?: IDetectionOptions
): Promise<IDetectionResult> {
// Quick sanity check
if (!buffer || buffer.length === 0) {
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
// Try TLS detection first (more specific)
const tlsDetector = new TlsDetector();
if (tlsDetector.canHandle(buffer)) {
const tlsResult = tlsDetector.detect(buffer, options);
if (tlsResult) {
return tlsResult;
}
}
// Try HTTP detection
const httpDetector = new HttpDetector();
if (httpDetector.canHandle(buffer)) {
const httpResult = httpDetector.detect(buffer, options);
if (httpResult) {
return httpResult;
}
}
// Neither TLS nor HTTP
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
/**
* Detect protocol with connection tracking for fragmented data
*
* @param buffer The buffer to analyze
* @param connectionId Unique connection identifier
* @param options Detection options
* @returns Detection result with protocol information
*/
static async detectWithConnectionTracking(
buffer: Buffer,
connectionId: string,
options?: IDetectionOptions
): Promise<IDetectionResult> {
// Initialize or get connection tracking
let tracking = this.connectionTracking.get(connectionId);
if (!tracking) {
tracking = { startTime: Date.now() };
this.connectionTracking.set(connectionId, tracking);
}
// Check timeout
if (options?.timeout) {
const elapsed = Date.now() - tracking.startTime;
if (elapsed > options.timeout) {
// Timeout - clean up and return unknown
this.connectionTracking.delete(connectionId);
TlsDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
HttpDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
}
// If we already know the protocol, use the appropriate detector
if (tracking.protocol === 'tls') {
const result = TlsDetector.detectWithFragments(buffer, connectionId, options);
if (result && result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result || {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
} else if (tracking.protocol === 'http') {
const result = HttpDetector.detectWithFragments(buffer, connectionId, options);
if (result && result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result || {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: true
};
}
// First time detection - try to determine protocol
// Quick checks first
if (buffer.length > 0) {
// TLS always starts with specific byte values
if (buffer[0] >= 0x14 && buffer[0] <= 0x18) {
tracking.protocol = 'tls';
const result = TlsDetector.detectWithFragments(buffer, connectionId, options);
if (result) {
if (result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result;
}
}
// HTTP starts with ASCII text
else if (HttpDetector.quickCheck(buffer)) {
tracking.protocol = 'http';
const result = HttpDetector.detectWithFragments(buffer, connectionId, options);
if (result) {
if (result.isComplete) {
this.connectionTracking.delete(connectionId);
}
return result;
}
}
}
// Can't determine protocol yet
return {
protocol: 'unknown',
connectionInfo: { protocol: 'unknown' },
isComplete: false,
bytesNeeded: 10 // Need more data to determine protocol
};
}
/**
* Clean up old connection tracking entries
*
* @param maxAge Maximum age in milliseconds (default: 30 seconds)
*/
static cleanupConnections(maxAge: number = 30000): void {
const now = Date.now();
const toDelete: string[] = [];
for (const [connectionId, tracking] of this.connectionTracking.entries()) {
if (now - tracking.startTime > maxAge) {
toDelete.push(connectionId);
}
}
for (const connectionId of toDelete) {
this.connectionTracking.delete(connectionId);
// Also clean up detector-specific buffers
TlsDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
HttpDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
}
// Also trigger cleanup in detectors
HttpDetector.cleanupFragments(maxAge);
}
/**
* Extract domain from connection info
*
* @param connectionInfo Connection information from detection
* @returns The domain/hostname if found
*/
static extractDomain(connectionInfo: IConnectionInfo): string | undefined {
// For both TLS and HTTP, domain is stored in the domain field
return connectionInfo.domain;
}
/**
* Create a connection ID from connection parameters
*
* @param params Connection parameters
* @returns A unique connection identifier
*/
static createConnectionId(params: {
sourceIp?: string;
sourcePort?: number;
destIp?: string;
destPort?: number;
socketId?: string;
}): string {
// If socketId is provided, use it
if (params.socketId) {
return params.socketId;
}
// Otherwise create from connection tuple
const { sourceIp = 'unknown', sourcePort = 0, destIp = 'unknown', destPort = 0 } = params;
return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
}
}

View File

@@ -0,0 +1,141 @@
/**
* Buffer manipulation utilities for protocol detection
*/
// Import from protocols
import { HttpParser } from '../../protocols/http/index.js';
/**
* BufferAccumulator class for handling fragmented data
*/
export class BufferAccumulator {
private chunks: Buffer[] = [];
private totalLength = 0;
/**
* Append data to the accumulator
*/
append(data: Buffer): void {
this.chunks.push(data);
this.totalLength += data.length;
}
/**
* Get the accumulated buffer
*/
getBuffer(): Buffer {
if (this.chunks.length === 0) {
return Buffer.alloc(0);
}
if (this.chunks.length === 1) {
return this.chunks[0];
}
return Buffer.concat(this.chunks, this.totalLength);
}
/**
* Get current buffer length
*/
length(): number {
return this.totalLength;
}
/**
* Clear all accumulated data
*/
clear(): void {
this.chunks = [];
this.totalLength = 0;
}
/**
* Check if accumulator has minimum bytes
*/
hasMinimumBytes(minBytes: number): boolean {
return this.totalLength >= minBytes;
}
}
/**
* Read a big-endian 16-bit integer from buffer
*/
export function readUInt16BE(buffer: Buffer, offset: number): number {
if (offset + 2 > buffer.length) {
throw new Error('Buffer too short for UInt16BE read');
}
return (buffer[offset] << 8) | buffer[offset + 1];
}
/**
* Read a big-endian 24-bit integer from buffer
*/
export function readUInt24BE(buffer: Buffer, offset: number): number {
if (offset + 3 > buffer.length) {
throw new Error('Buffer too short for UInt24BE read');
}
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
}
/**
* Find a byte sequence in a buffer
*/
export function findSequence(buffer: Buffer, sequence: Buffer, startOffset = 0): number {
if (sequence.length === 0) {
return startOffset;
}
const searchLength = buffer.length - sequence.length + 1;
for (let i = startOffset; i < searchLength; i++) {
let found = true;
for (let j = 0; j < sequence.length; j++) {
if (buffer[i + j] !== sequence[j]) {
found = false;
break;
}
}
if (found) {
return i;
}
}
return -1;
}
/**
* Extract a line from buffer (up to CRLF or LF)
*/
export function extractLine(buffer: Buffer, startOffset = 0): { line: string; nextOffset: number } | null {
// Delegate to protocol parser
return HttpParser.extractLine(buffer, startOffset);
}
/**
* Check if buffer starts with a string (case-insensitive)
*/
export function startsWithString(buffer: Buffer, str: string, offset = 0): boolean {
if (offset + str.length > buffer.length) {
return false;
}
const bufferStr = buffer.slice(offset, offset + str.length).toString('utf8');
return bufferStr.toLowerCase() === str.toLowerCase();
}
/**
* Safe buffer slice that doesn't throw on out-of-bounds
*/
export function safeSlice(buffer: Buffer, start: number, end?: number): Buffer {
const safeStart = Math.max(0, Math.min(start, buffer.length));
const safeEnd = end === undefined
? buffer.length
: Math.max(safeStart, Math.min(end, buffer.length));
return buffer.slice(safeStart, safeEnd);
}
/**
* Check if buffer contains printable ASCII
*/
export function isPrintableAscii(buffer: Buffer, length?: number): boolean {
// Delegate to protocol parser
return HttpParser.isPrintableAscii(buffer, length);
}

View File

@@ -0,0 +1,77 @@
/**
* Parser utilities for protocol detection
* Now delegates to protocol modules for actual parsing
*/
import type { THttpMethod, TTlsVersion } from '../models/detection-types.js';
import { HttpParser, HTTP_METHODS, HTTP_VERSIONS } from '../../protocols/http/index.js';
import { tlsVersionToString as protocolTlsVersionToString } from '../../protocols/tls/index.js';
// Re-export constants for backward compatibility
export { HTTP_METHODS, HTTP_VERSIONS };
/**
* Parse HTTP request line
*/
export function parseHttpRequestLine(line: string): {
method: THttpMethod;
path: string;
version: string;
} | null {
// Delegate to protocol parser
const result = HttpParser.parseRequestLine(line);
return result ? {
method: result.method as THttpMethod,
path: result.path,
version: result.version
} : null;
}
/**
* Parse HTTP header line
*/
export function parseHttpHeader(line: string): { name: string; value: string } | null {
// Delegate to protocol parser
return HttpParser.parseHeaderLine(line);
}
/**
* Parse HTTP headers from lines
*/
export function parseHttpHeaders(lines: string[]): Record<string, string> {
// Delegate to protocol parser
return HttpParser.parseHeaders(lines);
}
/**
* Convert TLS version bytes to version string
*/
export function tlsVersionToString(major: number, minor: number): TTlsVersion | null {
// Delegate to protocol parser
return protocolTlsVersionToString(major, minor) as TTlsVersion;
}
/**
* Extract domain from Host header value
*/
export function extractDomainFromHost(hostHeader: string): string {
// Delegate to protocol parser
return HttpParser.extractDomainFromHost(hostHeader);
}
/**
* Validate domain name
*/
export function isValidDomain(domain: string): boolean {
// Delegate to protocol parser
return HttpParser.isValidDomain(domain);
}
/**
* Check if string is a valid HTTP method
*/
export function isHttpMethod(str: string): str is THttpMethod {
// Delegate to protocol parser
return HttpParser.isHttpMethod(str) && (str as THttpMethod) !== undefined;
}

View File

@@ -1,76 +0,0 @@
import type * as plugins from '../../plugins.js';
/**
* The primary forwarding types supported by SmartProxy
* Used for configuration compatibility
*/
export type TForwardingType =
| 'http-only' // HTTP forwarding only (no HTTPS)
| 'https-passthrough' // Pass-through TLS traffic (SNI forwarding)
| 'https-terminate-to-http' // Terminate TLS and forward to HTTP backend
| 'https-terminate-to-https'; // Terminate TLS and forward to HTTPS backend
/**
* Event types emitted by forwarding handlers
*/
export enum ForwardingHandlerEvents {
CONNECTED = 'connected',
DISCONNECTED = 'disconnected',
ERROR = 'error',
DATA_FORWARDED = 'data-forwarded',
HTTP_REQUEST = 'http-request',
HTTP_RESPONSE = 'http-response',
CERTIFICATE_NEEDED = 'certificate-needed',
CERTIFICATE_LOADED = 'certificate-loaded'
}
/**
* Base interface for forwarding handlers
*/
export interface IForwardingHandler extends plugins.EventEmitter {
initialize(): Promise<void>;
handleConnection(socket: plugins.net.Socket): void;
handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
}
// Route-based helpers are now available directly from route-patterns.ts
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../../proxies/smart-proxy/utils/route-patterns.js';
export {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
};
// Note: Legacy helper functions have been removed
// Please use the route-based helpers instead:
// - createHttpRoute
// - createHttpsTerminateRoute
// - createHttpsPassthroughRoute
// - createHttpToHttpsRedirect
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
// For backward compatibility, kept only the basic configuration interface
export interface IForwardConfig {
type: TForwardingType;
target: {
host: string | string[];
port: number | 'preserve' | ((ctx: any) => number);
};
http?: any;
https?: any;
acme?: any;
security?: any;
advanced?: any;
[key: string]: any;
}

View File

@@ -1,26 +0,0 @@
/**
* Forwarding configuration exports
*
* Note: The legacy domain-based configuration has been replaced by route-based configuration.
* See /ts/proxies/smart-proxy/models/route-types.ts for the new route-based configuration.
*/
export type {
TForwardingType,
IForwardConfig,
IForwardingHandler
} from './forwarding-types.js';
export {
ForwardingHandlerEvents
} from './forwarding-types.js';
// Import route helpers from route-patterns instead of deleted route-helpers
export {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../../proxies/smart-proxy/utils/route-patterns.js';

View File

@@ -1,189 +0,0 @@
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandler } from '../handlers/base-handler.js';
import { HttpForwardingHandler } from '../handlers/http-handler.js';
import { HttpsPassthroughHandler } from '../handlers/https-passthrough-handler.js';
import { HttpsTerminateToHttpHandler } from '../handlers/https-terminate-to-http-handler.js';
import { HttpsTerminateToHttpsHandler } from '../handlers/https-terminate-to-https-handler.js';
/**
* Factory for creating forwarding handlers based on the configuration type
*/
export class ForwardingHandlerFactory {
/**
* Create a forwarding handler based on the configuration
* @param config The forwarding configuration
* @returns The appropriate forwarding handler
*/
public static createHandler(config: IForwardConfig): ForwardingHandler {
// Create the appropriate handler based on the forwarding type
switch (config.type) {
case 'http-only':
return new HttpForwardingHandler(config);
case 'https-passthrough':
return new HttpsPassthroughHandler(config);
case 'https-terminate-to-http':
return new HttpsTerminateToHttpHandler(config);
case 'https-terminate-to-https':
return new HttpsTerminateToHttpsHandler(config);
default:
// Type system should prevent this, but just in case:
throw new Error(`Unknown forwarding type: ${(config as any).type}`);
}
}
/**
* Apply default values to a forwarding configuration based on its type
* @param config The original forwarding configuration
* @returns A configuration with defaults applied
*/
public static applyDefaults(config: IForwardConfig): IForwardConfig {
// Create a deep copy of the configuration
const result: IForwardConfig = JSON.parse(JSON.stringify(config));
// Apply defaults based on forwarding type
switch (config.type) {
case 'http-only':
// Set defaults for HTTP-only mode
result.http = {
enabled: true,
...config.http
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 80;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
case 'https-passthrough':
// Set defaults for HTTPS passthrough
result.https = {
forwardSni: true,
...config.https
};
// SNI forwarding doesn't do HTTP
result.http = {
enabled: false,
...config.http
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 443;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
case 'https-terminate-to-http':
// Set defaults for HTTPS termination to HTTP
result.https = {
...config.https
};
// Support HTTP access by default in this mode
result.http = {
enabled: true,
redirectToHttps: true,
...config.http
};
// Enable ACME by default
result.acme = {
enabled: true,
maintenance: true,
...config.acme
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 443;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
case 'https-terminate-to-https':
// Similar to terminate-to-http but with different target handling
result.https = {
...config.https
};
result.http = {
enabled: true,
redirectToHttps: true,
...config.http
};
result.acme = {
enabled: true,
maintenance: true,
...config.acme
};
// Set default port and socket if not provided
if (!result.port) {
result.port = 443;
}
if (!result.socket) {
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
}
break;
}
return result;
}
/**
* Validate a forwarding configuration
* @param config The configuration to validate
* @throws Error if the configuration is invalid
*/
public static validateConfig(config: IForwardConfig): void {
// Validate common properties
if (!config.target) {
throw new Error('Forwarding configuration must include a target');
}
if (!config.target.host || (Array.isArray(config.target.host) && config.target.host.length === 0)) {
throw new Error('Target must include a host or array of hosts');
}
// Validate port if it's a number
if (typeof config.target.port === 'number') {
if (config.target.port <= 0 || config.target.port > 65535) {
throw new Error('Target must include a valid port (1-65535)');
}
} else if (config.target.port !== 'preserve' && typeof config.target.port !== 'function') {
throw new Error('Target port must be a number, "preserve", or a function');
}
// Type-specific validation
switch (config.type) {
case 'http-only':
// HTTP-only needs http.enabled to be true
if (config.http?.enabled === false) {
throw new Error('HTTP-only forwarding must have HTTP enabled');
}
break;
case 'https-passthrough':
// HTTPS passthrough doesn't support HTTP
if (config.http?.enabled === true) {
throw new Error('HTTPS passthrough does not support HTTP');
}
// HTTPS passthrough doesn't work with ACME
if (config.acme?.enabled === true) {
throw new Error('HTTPS passthrough does not support ACME');
}
break;
case 'https-terminate-to-http':
case 'https-terminate-to-https':
// These modes support all options, nothing specific to validate
break;
}
}
}

View File

@@ -1,5 +0,0 @@
/**
* Forwarding factory implementations
*/
export { ForwardingHandlerFactory } from './forwarding-factory.js';

View File

@@ -1,155 +0,0 @@
import * as plugins from '../../plugins.js';
import type {
IForwardConfig,
IForwardingHandler
} from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
/**
* Base class for all forwarding handlers
*/
export abstract class ForwardingHandler extends plugins.EventEmitter implements IForwardingHandler {
/**
* Create a new ForwardingHandler
* @param config The forwarding configuration
*/
constructor(protected config: IForwardConfig) {
super();
}
/**
* Initialize the handler
* Base implementation does nothing, subclasses should override as needed
*/
public async initialize(): Promise<void> {
// Base implementation - no initialization needed
}
/**
* Handle a new socket connection
* @param socket The incoming socket connection
*/
public abstract handleConnection(socket: plugins.net.Socket): void;
/**
* Handle an HTTP request
* @param req The HTTP request
* @param res The HTTP response
*/
public abstract handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
/**
* Get a target from the configuration, supporting round-robin selection
* @param incomingPort Optional incoming port for 'preserve' mode
* @returns A resolved target object with host and port
*/
protected getTargetFromConfig(incomingPort: number = 80): { host: string, port: number } {
const { target } = this.config;
// Handle round-robin host selection
if (Array.isArray(target.host)) {
if (target.host.length === 0) {
throw new Error('No target hosts specified');
}
// Simple round-robin selection
const randomIndex = Math.floor(Math.random() * target.host.length);
return {
host: target.host[randomIndex],
port: this.resolvePort(target.port, incomingPort)
};
}
// Single host
return {
host: target.host,
port: this.resolvePort(target.port, incomingPort)
};
}
/**
* Resolves a port value, handling 'preserve' and function ports
* @param port The port value to resolve
* @param incomingPort Optional incoming port to use for 'preserve' mode
*/
protected resolvePort(
port: number | 'preserve' | ((ctx: any) => number),
incomingPort: number = 80
): number {
if (typeof port === 'function') {
try {
// Create a minimal context for the function that includes the incoming port
const ctx = { port: incomingPort };
return port(ctx);
} catch (err) {
console.error('Error resolving port function:', err);
return incomingPort; // Fall back to incoming port
}
} else if (port === 'preserve') {
return incomingPort; // Use the actual incoming port for 'preserve'
} else {
return port;
}
}
/**
* Redirect an HTTP request to HTTPS
* @param req The HTTP request
* @param res The HTTP response
*/
protected redirectToHttps(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
const host = req.headers.host || '';
const path = req.url || '/';
const redirectUrl = `https://${host}${path}`;
res.writeHead(301, {
'Location': redirectUrl,
'Cache-Control': 'no-cache'
});
res.end(`Redirecting to ${redirectUrl}`);
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: 301,
headers: { 'Location': redirectUrl },
size: 0
});
}
/**
* Apply custom headers from configuration
* @param headers The original headers
* @param variables Variables to replace in the headers
* @returns The headers with custom values applied
*/
protected applyCustomHeaders(
headers: Record<string, string | string[] | undefined>,
variables: Record<string, string>
): Record<string, string | string[] | undefined> {
const customHeaders = this.config.advanced?.headers || {};
const result = { ...headers };
// Apply custom headers with variable substitution
for (const [key, value] of Object.entries(customHeaders)) {
if (typeof value !== 'string') continue;
let processedValue = value;
// Replace variables in the header value
for (const [varName, varValue] of Object.entries(variables)) {
processedValue = processedValue.replace(`{${varName}}`, varValue);
}
result[key] = processedValue;
}
return result;
}
/**
* Get the timeout for this connection from configuration
* @returns Timeout in milliseconds
*/
protected getTimeout(): number {
return this.config.advanced?.timeout || 60000; // Default: 60 seconds
}
}

View File

@@ -1,163 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { setupSocketHandlers } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTP-only forwarding
*/
export class HttpForwardingHandler extends ForwardingHandler {
/**
* Create a new HTTP forwarding handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTP-only configuration
if (config.type !== 'http-only') {
throw new Error(`Invalid configuration type for HttpForwardingHandler: ${config.type}`);
}
}
/**
* Initialize the handler
* HTTP handler doesn't need special initialization
*/
public async initialize(): Promise<void> {
// Basic initialization from parent class
await super.initialize();
}
/**
* Handle a raw socket connection
* HTTP handler doesn't do much with raw sockets as it mainly processes
* parsed HTTP requests
*/
public handleConnection(socket: plugins.net.Socket): void {
// For HTTP, we mainly handle parsed requests, but we can still set up
// some basic connection tracking
const remoteAddress = socket.remoteAddress || 'unknown';
const localPort = socket.localPort || 80;
// Set up socket handlers with proper cleanup
const handleClose = (reason: string) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
};
// Use custom timeout handler that doesn't close the socket
setupSocketHandlers(socket, handleClose, () => {
// For HTTP, we can be more aggressive with timeouts since connections are shorter
// But still don't close immediately - let the connection finish naturally
console.warn(`HTTP socket timeout from ${remoteAddress}`);
}, 'http');
socket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: error.message
});
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
localPort
});
}
/**
* Handle an HTTP request
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Get the local port from the request (for 'preserve' port handling)
const localPort = req.socket.localPort || 80;
// Get the target from configuration, passing the incoming port
const target = this.getTargetFromConfig(localPort);
// Create a custom headers object with variables for substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers
};
// Create the proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track bytes for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@@ -1,185 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS passthrough (SNI forwarding without termination)
*/
export class HttpsPassthroughHandler extends ForwardingHandler {
/**
* Create a new HTTPS passthrough handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS passthrough configuration
if (config.type !== 'https-passthrough') {
throw new Error(`Invalid configuration type for HttpsPassthroughHandler: ${config.type}`);
}
}
/**
* Initialize the handler
* HTTPS passthrough handler doesn't need special initialization
*/
public async initialize(): Promise<void> {
// Basic initialization from parent class
await super.initialize();
}
/**
* Handle a TLS/SSL socket connection by forwarding it without termination
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Get the target from configuration
const target = this.getTargetFromConfig();
// Log the connection
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
target: `${target.host}:${target.port}`
});
// Track data transfer for logging
let bytesSent = 0;
let bytesReceived = 0;
let serverSocket: plugins.net.Socket | null = null;
let cleanupClient: ((reason: string) => Promise<void>) | null = null;
let cleanupServer: ((reason: string) => Promise<void>) | null = null;
// Create a connection to the target server with immediate error handling
serverSocket = createSocketWithErrorHandler({
port: target.port,
host: target.host,
onError: async (error) => {
// Server connection failed - clean up client socket immediately
this.emit(ForwardingHandlerEvents.ERROR, {
error: error.message,
code: (error as any).code || 'UNKNOWN',
remoteAddress,
target: `${target.host}:${target.port}`
});
// Clean up the client socket since we can't forward
if (!clientSocket.destroyed) {
clientSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
bytesSent: 0,
bytesReceived: 0,
reason: `server_connection_failed: ${error.message}`
});
},
onConnect: () => {
// Connection successful - set up forwarding handlers
const handlers = createIndependentSocketHandlers(
clientSocket,
serverSocket!,
(reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
bytesSent,
bytesReceived,
reason
});
}
);
cleanupClient = handlers.cleanupClient;
cleanupServer = handlers.cleanupServer;
// Setup handlers with custom timeout handling that doesn't close connections
const timeout = this.getTimeout();
setupSocketHandlers(clientSocket, cleanupClient, (socket) => {
// Just reset timeout, don't close
socket.setTimeout(timeout);
}, 'client');
setupSocketHandlers(serverSocket!, cleanupServer, (socket) => {
// Just reset timeout, don't close
socket.setTimeout(timeout);
}, 'server');
// Forward data from client to server
clientSocket.on('data', (data) => {
bytesSent += data.length;
// Check if server socket is writable
if (serverSocket && serverSocket.writable) {
const flushed = serverSocket.write(data);
// Handle backpressure
if (!flushed) {
clientSocket.pause();
serverSocket.once('drain', () => {
clientSocket.resume();
});
}
}
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
bytes: data.length,
total: bytesSent
});
});
// Forward data from server to client
serverSocket!.on('data', (data) => {
bytesReceived += data.length;
// Check if client socket is writable
if (clientSocket.writable) {
const flushed = clientSocket.write(data);
// Handle backpressure
if (!flushed) {
serverSocket!.pause();
clientSocket.once('drain', () => {
serverSocket!.resume();
});
}
}
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'inbound',
bytes: data.length,
total: bytesReceived
});
});
// Set initial timeouts - they will be reset on each timeout event
clientSocket.setTimeout(timeout);
serverSocket!.setTimeout(timeout);
}
});
}
/**
* Handle an HTTP request - HTTPS passthrough doesn't support HTTP
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(_req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// HTTPS passthrough doesn't support HTTP requests
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('HTTP not supported for this domain');
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: 404,
headers: { 'Content-Type': 'text/plain' },
size: 'HTTP not supported for this domain'.length
});
}
}

View File

@@ -1,312 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS termination with HTTP backend
*/
export class HttpsTerminateToHttpHandler extends ForwardingHandler {
private tlsServer: plugins.tls.Server | null = null;
private secureContext: plugins.tls.SecureContext | null = null;
/**
* Create a new HTTPS termination with HTTP backend handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS terminate to HTTP configuration
if (config.type !== 'https-terminate-to-http') {
throw new Error(`Invalid configuration type for HttpsTerminateToHttpHandler: ${config.type}`);
}
}
/**
* Initialize the handler, setting up TLS context
*/
public async initialize(): Promise<void> {
// We need to load or create TLS certificates
if (this.config.https?.customCert) {
// Use custom certificate from configuration
this.secureContext = plugins.tls.createSecureContext({
key: this.config.https.customCert.key,
cert: this.config.https.customCert.cert
});
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
source: 'config',
domain: this.config.target.host
});
} else if (this.config.acme?.enabled) {
// Request certificate through ACME if needed
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
domain: Array.isArray(this.config.target.host)
? this.config.target.host[0]
: this.config.target.host,
useProduction: this.config.acme.production || false
});
// In a real implementation, we would wait for the certificate to be issued
// For now, we'll use a dummy context
this.secureContext = plugins.tls.createSecureContext({
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
});
} else {
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
}
}
/**
* Set the secure context for TLS termination
* Called when a certificate is available
* @param context The secure context
*/
public setSecureContext(context: plugins.tls.SecureContext): void {
this.secureContext = context;
}
/**
* Handle a TLS/SSL socket connection by terminating TLS and forwarding to HTTP backend
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Make sure we have a secure context
if (!this.secureContext) {
clientSocket.destroy(new Error('TLS secure context not initialized'));
return;
}
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
// Create a TLS socket using our secure context
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
secureContext: this.secureContext,
isServer: true,
server: this.tlsServer || undefined
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
tls: true
});
// Variables to track connections
let backendSocket: plugins.net.Socket | null = null;
let dataBuffer = Buffer.alloc(0);
let connectionEstablished = false;
let forwardingSetup = false;
// Set up initial error handling for TLS socket
const tlsCleanupHandler = (reason: string) => {
if (!forwardingSetup) {
// If forwarding not set up yet, emit disconnected and cleanup
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
if (backendSocket && !backendSocket.destroyed) {
backendSocket.destroy();
}
}
// If forwarding is setup, setupBidirectionalForwarding will handle cleanup
};
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
// Set timeout
const timeout = this.getTimeout();
tlsSocket.setTimeout(timeout);
tlsSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'TLS connection timeout'
});
tlsCleanupHandler('timeout');
});
// Handle TLS data
tlsSocket.on('data', (data) => {
// If backend connection already established, just forward the data
if (connectionEstablished && backendSocket && !backendSocket.destroyed) {
backendSocket.write(data);
return;
}
// Append to buffer
dataBuffer = Buffer.concat([dataBuffer, data]);
// Very basic HTTP parsing - in a real implementation, use http-parser
if (dataBuffer.includes(Buffer.from('\r\n\r\n')) && !connectionEstablished) {
const target = this.getTargetFromConfig();
// Create backend connection with immediate error handling
backendSocket = createSocketWithErrorHandler({
port: target.port,
host: target.host,
onError: (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
error: error.message,
code: (error as any).code || 'UNKNOWN',
remoteAddress,
target: `${target.host}:${target.port}`
});
// Clean up the TLS socket since we can't forward
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason: `backend_connection_failed: ${error.message}`
});
},
onConnect: () => {
connectionEstablished = true;
// Send buffered data
if (dataBuffer.length > 0) {
backendSocket!.write(dataBuffer);
dataBuffer = Buffer.alloc(0);
}
// Now set up bidirectional forwarding with proper cleanup
forwardingSetup = true;
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
dataBuffer = Buffer.alloc(0);
connectionEstablished = false;
forwardingSetup = false;
},
enableHalfOpen: false // Close both when one closes
});
}
});
// Additional error logging for backend socket
backendSocket.on('error', (error) => {
if (!connectionEstablished) {
// Connection failed during setup
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Target connection error: ${error.message}`
});
}
// If connected, setupBidirectionalForwarding handles cleanup
});
}
});
}
/**
* Handle an HTTP request by forwarding to the HTTP backend
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Check if we should redirect to HTTPS
if (this.config.http?.redirectToHttps) {
this.redirectToHttps(req, res);
return;
}
// Get the target from configuration
const target = this.getTargetFromConfig();
// Create custom headers with variable substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers
};
// Create the proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track response size for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@@ -1,297 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './base-handler.js';
import type { IForwardConfig } from '../config/forwarding-types.js';
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
/**
* Handler for HTTPS termination with HTTPS backend
*/
export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
private secureContext: plugins.tls.SecureContext | null = null;
/**
* Create a new HTTPS termination with HTTPS backend handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS terminate to HTTPS configuration
if (config.type !== 'https-terminate-to-https') {
throw new Error(`Invalid configuration type for HttpsTerminateToHttpsHandler: ${config.type}`);
}
}
/**
* Initialize the handler, setting up TLS context
*/
public async initialize(): Promise<void> {
// We need to load or create TLS certificates for termination
if (this.config.https?.customCert) {
// Use custom certificate from configuration
this.secureContext = plugins.tls.createSecureContext({
key: this.config.https.customCert.key,
cert: this.config.https.customCert.cert
});
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
source: 'config',
domain: this.config.target.host
});
} else if (this.config.acme?.enabled) {
// Request certificate through ACME if needed
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
domain: Array.isArray(this.config.target.host)
? this.config.target.host[0]
: this.config.target.host,
useProduction: this.config.acme.production || false
});
// In a real implementation, we would wait for the certificate to be issued
// For now, we'll use a dummy context
this.secureContext = plugins.tls.createSecureContext({
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
});
} else {
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
}
}
/**
* Set the secure context for TLS termination
* Called when a certificate is available
* @param context The secure context
*/
public setSecureContext(context: plugins.tls.SecureContext): void {
this.secureContext = context;
}
/**
* Handle a TLS/SSL socket connection by terminating TLS and creating a new TLS connection to backend
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Make sure we have a secure context
if (!this.secureContext) {
clientSocket.destroy(new Error('TLS secure context not initialized'));
return;
}
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
// Create a TLS socket using our secure context
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
secureContext: this.secureContext,
isServer: true
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
tls: true
});
// Variable to track backend socket
let backendSocket: plugins.tls.TLSSocket | null = null;
let isConnectedToBackend = false;
// Set up initial error handling for TLS socket
const tlsCleanupHandler = (reason: string) => {
if (!isConnectedToBackend) {
// If backend not connected yet, just emit disconnected event
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
// Cleanup TLS socket if needed
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
}
// If connected to backend, setupBidirectionalForwarding will handle cleanup
};
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
// Set timeout
const timeout = this.getTimeout();
tlsSocket.setTimeout(timeout);
tlsSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'TLS connection timeout'
});
tlsCleanupHandler('timeout');
});
// Get the target from configuration
const target = this.getTargetFromConfig();
// Set up the connection to the HTTPS backend
const connectToBackend = () => {
backendSocket = plugins.tls.connect({
host: target.host,
port: target.port,
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
}, () => {
isConnectedToBackend = true;
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
target: `${target.host}:${target.port}`,
tls: true
});
// Set up bidirectional forwarding with proper cleanup
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
onCleanup: (reason) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason
});
},
enableHalfOpen: false // Close both when one closes
});
// Set timeout for backend socket
backendSocket!.setTimeout(timeout);
backendSocket!.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Backend connection timeout'
});
// Let setupBidirectionalForwarding handle the cleanup
});
});
// Handle backend connection errors
backendSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Backend connection error: ${error.message}`
});
if (!isConnectedToBackend) {
// Connection failed, clean up TLS socket
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
reason: `backend_connection_failed: ${error.message}`
});
}
// If connected, let setupBidirectionalForwarding handle cleanup
});
};
// Wait for the TLS handshake to complete before connecting to backend
tlsSocket.on('secure', () => {
connectToBackend();
});
}
/**
* Handle an HTTP request by forwarding to the HTTPS backend
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Check if we should redirect to HTTPS
if (this.config.http?.redirectToHttps) {
this.redirectToHttps(req, res);
return;
}
// Get the target from configuration
const target = this.getTargetFromConfig();
// Create custom headers with variable substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers,
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
};
// Create the proxy request using HTTPS
const proxyReq = plugins.https.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track response size for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@@ -1,9 +0,0 @@
/**
* Forwarding handler implementations
*/
export { ForwardingHandler } from './base-handler.js';
export { HttpForwardingHandler } from './http-handler.js';
export { HttpsPassthroughHandler } from './https-passthrough-handler.js';
export { HttpsTerminateToHttpHandler } from './https-terminate-to-http-handler.js';
export { HttpsTerminateToHttpsHandler } from './https-terminate-to-https-handler.js';

View File

@@ -1,35 +0,0 @@
/**
* Forwarding system module
* Provides a flexible and type-safe way to configure and manage various forwarding strategies
*/
// Export handlers
export { ForwardingHandler } from './handlers/base-handler.js';
export * from './handlers/http-handler.js';
export * from './handlers/https-passthrough-handler.js';
export * from './handlers/https-terminate-to-http-handler.js';
export * from './handlers/https-terminate-to-https-handler.js';
// Export factory
export * from './factory/forwarding-factory.js';
// Export types - these include TForwardingType and IForwardConfig
export type {
TForwardingType,
IForwardConfig,
IForwardingHandler
} from './config/forwarding-types.js';
export {
ForwardingHandlerEvents
} from './config/forwarding-types.js';
// Export route helpers directly from route-patterns
export {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../proxies/smart-proxy/utils/route-patterns.js';

View File

@@ -32,7 +32,8 @@ export * from './core/models/common-types.js';
export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js'; export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js';
// Modular exports for new architecture // Modular exports for new architecture
export * as forwarding from './forwarding/index.js';
// Certificate module has been removed - use SmartCertManager instead // Certificate module has been removed - use SmartCertManager instead
export * as tls from './tls/index.js'; export * as tls from './tls/index.js';
export * as routing from './routing/index.js'; export * as routing from './routing/index.js';
export * as detection from './detection/index.js';
export * as protocols from './protocols/index.js';

View File

@@ -0,0 +1,219 @@
/**
* HTTP Protocol Constants
*/
/**
* HTTP methods
*/
export const HTTP_METHODS = [
'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE'
] as const;
export type THttpMethod = typeof HTTP_METHODS[number];
/**
* HTTP version strings
*/
export const HTTP_VERSIONS = ['HTTP/1.0', 'HTTP/1.1', 'HTTP/2', 'HTTP/3'] as const;
export type THttpVersion = typeof HTTP_VERSIONS[number];
/**
* HTTP status codes
*/
export enum HttpStatus {
// 1xx Informational
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
EARLY_HINTS = 103,
// 2xx Success
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
ALREADY_REPORTED = 208,
IM_USED = 226,
// 3xx Redirection
MULTIPLE_CHOICES = 300,
MOVED_PERMANENTLY = 301,
FOUND = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
USE_PROXY = 305,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,
// 4xx Client Error
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
PAYMENT_REQUIRED = 402,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
CONFLICT = 409,
GONE = 410,
LENGTH_REQUIRED = 411,
PRECONDITION_FAILED = 412,
PAYLOAD_TOO_LARGE = 413,
URI_TOO_LONG = 414,
UNSUPPORTED_MEDIA_TYPE = 415,
RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
IM_A_TEAPOT = 418,
MISDIRECTED_REQUEST = 421,
UNPROCESSABLE_ENTITY = 422,
LOCKED = 423,
FAILED_DEPENDENCY = 424,
TOO_EARLY = 425,
UPGRADE_REQUIRED = 426,
PRECONDITION_REQUIRED = 428,
TOO_MANY_REQUESTS = 429,
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
// 5xx Server Error
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
HTTP_VERSION_NOT_SUPPORTED = 505,
VARIANT_ALSO_NEGOTIATES = 506,
INSUFFICIENT_STORAGE = 507,
LOOP_DETECTED = 508,
NOT_EXTENDED = 510,
NETWORK_AUTHENTICATION_REQUIRED = 511,
}
/**
* HTTP status text mapping
*/
export const HTTP_STATUS_TEXT: Record<HttpStatus, string> = {
// 1xx
[HttpStatus.CONTINUE]: 'Continue',
[HttpStatus.SWITCHING_PROTOCOLS]: 'Switching Protocols',
[HttpStatus.PROCESSING]: 'Processing',
[HttpStatus.EARLY_HINTS]: 'Early Hints',
// 2xx
[HttpStatus.OK]: 'OK',
[HttpStatus.CREATED]: 'Created',
[HttpStatus.ACCEPTED]: 'Accepted',
[HttpStatus.NON_AUTHORITATIVE_INFORMATION]: 'Non-Authoritative Information',
[HttpStatus.NO_CONTENT]: 'No Content',
[HttpStatus.RESET_CONTENT]: 'Reset Content',
[HttpStatus.PARTIAL_CONTENT]: 'Partial Content',
[HttpStatus.MULTI_STATUS]: 'Multi-Status',
[HttpStatus.ALREADY_REPORTED]: 'Already Reported',
[HttpStatus.IM_USED]: 'IM Used',
// 3xx
[HttpStatus.MULTIPLE_CHOICES]: 'Multiple Choices',
[HttpStatus.MOVED_PERMANENTLY]: 'Moved Permanently',
[HttpStatus.FOUND]: 'Found',
[HttpStatus.SEE_OTHER]: 'See Other',
[HttpStatus.NOT_MODIFIED]: 'Not Modified',
[HttpStatus.USE_PROXY]: 'Use Proxy',
[HttpStatus.TEMPORARY_REDIRECT]: 'Temporary Redirect',
[HttpStatus.PERMANENT_REDIRECT]: 'Permanent Redirect',
// 4xx
[HttpStatus.BAD_REQUEST]: 'Bad Request',
[HttpStatus.UNAUTHORIZED]: 'Unauthorized',
[HttpStatus.PAYMENT_REQUIRED]: 'Payment Required',
[HttpStatus.FORBIDDEN]: 'Forbidden',
[HttpStatus.NOT_FOUND]: 'Not Found',
[HttpStatus.METHOD_NOT_ALLOWED]: 'Method Not Allowed',
[HttpStatus.NOT_ACCEPTABLE]: 'Not Acceptable',
[HttpStatus.PROXY_AUTHENTICATION_REQUIRED]: 'Proxy Authentication Required',
[HttpStatus.REQUEST_TIMEOUT]: 'Request Timeout',
[HttpStatus.CONFLICT]: 'Conflict',
[HttpStatus.GONE]: 'Gone',
[HttpStatus.LENGTH_REQUIRED]: 'Length Required',
[HttpStatus.PRECONDITION_FAILED]: 'Precondition Failed',
[HttpStatus.PAYLOAD_TOO_LARGE]: 'Payload Too Large',
[HttpStatus.URI_TOO_LONG]: 'URI Too Long',
[HttpStatus.UNSUPPORTED_MEDIA_TYPE]: 'Unsupported Media Type',
[HttpStatus.RANGE_NOT_SATISFIABLE]: 'Range Not Satisfiable',
[HttpStatus.EXPECTATION_FAILED]: 'Expectation Failed',
[HttpStatus.IM_A_TEAPOT]: "I'm a teapot",
[HttpStatus.MISDIRECTED_REQUEST]: 'Misdirected Request',
[HttpStatus.UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
[HttpStatus.LOCKED]: 'Locked',
[HttpStatus.FAILED_DEPENDENCY]: 'Failed Dependency',
[HttpStatus.TOO_EARLY]: 'Too Early',
[HttpStatus.UPGRADE_REQUIRED]: 'Upgrade Required',
[HttpStatus.PRECONDITION_REQUIRED]: 'Precondition Required',
[HttpStatus.TOO_MANY_REQUESTS]: 'Too Many Requests',
[HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE]: 'Request Header Fields Too Large',
[HttpStatus.UNAVAILABLE_FOR_LEGAL_REASONS]: 'Unavailable For Legal Reasons',
// 5xx
[HttpStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error',
[HttpStatus.NOT_IMPLEMENTED]: 'Not Implemented',
[HttpStatus.BAD_GATEWAY]: 'Bad Gateway',
[HttpStatus.SERVICE_UNAVAILABLE]: 'Service Unavailable',
[HttpStatus.GATEWAY_TIMEOUT]: 'Gateway Timeout',
[HttpStatus.HTTP_VERSION_NOT_SUPPORTED]: 'HTTP Version Not Supported',
[HttpStatus.VARIANT_ALSO_NEGOTIATES]: 'Variant Also Negotiates',
[HttpStatus.INSUFFICIENT_STORAGE]: 'Insufficient Storage',
[HttpStatus.LOOP_DETECTED]: 'Loop Detected',
[HttpStatus.NOT_EXTENDED]: 'Not Extended',
[HttpStatus.NETWORK_AUTHENTICATION_REQUIRED]: 'Network Authentication Required',
};
/**
* Common HTTP headers
*/
export const HTTP_HEADERS = {
// Request headers
HOST: 'host',
USER_AGENT: 'user-agent',
ACCEPT: 'accept',
ACCEPT_LANGUAGE: 'accept-language',
ACCEPT_ENCODING: 'accept-encoding',
AUTHORIZATION: 'authorization',
CACHE_CONTROL: 'cache-control',
CONNECTION: 'connection',
CONTENT_TYPE: 'content-type',
CONTENT_LENGTH: 'content-length',
COOKIE: 'cookie',
// Response headers
SET_COOKIE: 'set-cookie',
LOCATION: 'location',
SERVER: 'server',
DATE: 'date',
EXPIRES: 'expires',
LAST_MODIFIED: 'last-modified',
ETAG: 'etag',
// CORS headers
ACCESS_CONTROL_ALLOW_ORIGIN: 'access-control-allow-origin',
ACCESS_CONTROL_ALLOW_METHODS: 'access-control-allow-methods',
ACCESS_CONTROL_ALLOW_HEADERS: 'access-control-allow-headers',
// Security headers
STRICT_TRANSPORT_SECURITY: 'strict-transport-security',
X_CONTENT_TYPE_OPTIONS: 'x-content-type-options',
X_FRAME_OPTIONS: 'x-frame-options',
X_XSS_PROTECTION: 'x-xss-protection',
CONTENT_SECURITY_POLICY: 'content-security-policy',
} as const;
/**
* Get HTTP status text
*/
export function getStatusText(status: HttpStatus): string {
return HTTP_STATUS_TEXT[status] || 'Unknown';
}

View File

@@ -0,0 +1,8 @@
/**
* HTTP Protocol Module
* Generic HTTP protocol knowledge and parsing utilities
*/
export * from './constants.js';
export * from './types.js';
export * from './parser.js';

219
ts/protocols/http/parser.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* HTTP Protocol Parser
* Generic HTTP parsing utilities
*/
import { HTTP_METHODS, type THttpMethod, type THttpVersion } from './constants.js';
import type { IHttpRequestLine, IHttpHeader } from './types.js';
/**
* HTTP parser utilities
*/
export class HttpParser {
/**
* Check if string is a valid HTTP method
*/
static isHttpMethod(str: string): str is THttpMethod {
return HTTP_METHODS.includes(str as THttpMethod);
}
/**
* Parse HTTP request line
*/
static parseRequestLine(line: string): IHttpRequestLine | null {
const parts = line.trim().split(' ');
if (parts.length !== 3) {
return null;
}
const [method, path, version] = parts;
// Validate method
if (!this.isHttpMethod(method)) {
return null;
}
// Validate version
if (!version.startsWith('HTTP/')) {
return null;
}
return {
method: method as THttpMethod,
path,
version: version as THttpVersion
};
}
/**
* Parse HTTP header line
*/
static parseHeaderLine(line: string): IHttpHeader | null {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) {
return null;
}
const name = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
if (!name) {
return null;
}
return { name, value };
}
/**
* Parse HTTP headers from lines
*/
static parseHeaders(lines: string[]): Record<string, string> {
const headers: Record<string, string> = {};
for (const line of lines) {
const header = this.parseHeaderLine(line);
if (header) {
// Convert header names to lowercase for consistency
headers[header.name.toLowerCase()] = header.value;
}
}
return headers;
}
/**
* Extract domain from Host header value
*/
static extractDomainFromHost(hostHeader: string): string {
// Remove port if present
const colonIndex = hostHeader.lastIndexOf(':');
if (colonIndex !== -1) {
// Check if it's not part of IPv6 address
const beforeColon = hostHeader.slice(0, colonIndex);
if (!beforeColon.includes(']')) {
return beforeColon;
}
}
return hostHeader;
}
/**
* Validate domain name
*/
static isValidDomain(domain: string): boolean {
// Basic domain validation
if (!domain || domain.length > 253) {
return false;
}
// Check for valid characters and structure
const domainRegex = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})*$/;
return domainRegex.test(domain);
}
/**
* Extract line from buffer
*/
static extractLine(buffer: Buffer, offset: number = 0): { line: string; nextOffset: number } | null {
// Look for CRLF
const crlfIndex = buffer.indexOf('\r\n', offset);
if (crlfIndex === -1) {
// Look for just LF
const lfIndex = buffer.indexOf('\n', offset);
if (lfIndex === -1) {
return null;
}
return {
line: buffer.slice(offset, lfIndex).toString('utf8'),
nextOffset: lfIndex + 1
};
}
return {
line: buffer.slice(offset, crlfIndex).toString('utf8'),
nextOffset: crlfIndex + 2
};
}
/**
* Check if buffer contains printable ASCII
*/
static isPrintableAscii(buffer: Buffer, length?: number): boolean {
const checkLength = Math.min(length || buffer.length, buffer.length);
for (let i = 0; i < checkLength; i++) {
const byte = buffer[i];
// Allow printable ASCII (32-126) plus tab (9), LF (10), and CR (13)
if (byte < 32 || byte > 126) {
if (byte !== 9 && byte !== 10 && byte !== 13) {
return false;
}
}
}
return true;
}
/**
* Quick check if buffer starts with HTTP method
*/
static quickCheck(buffer: Buffer): boolean {
if (buffer.length < 3) {
return false;
}
// Check common HTTP methods
const start = buffer.slice(0, 7).toString('ascii');
return start.startsWith('GET ') ||
start.startsWith('POST ') ||
start.startsWith('PUT ') ||
start.startsWith('DELETE ') ||
start.startsWith('HEAD ') ||
start.startsWith('OPTIONS') ||
start.startsWith('PATCH ') ||
start.startsWith('CONNECT') ||
start.startsWith('TRACE ');
}
/**
* Parse query string
*/
static parseQueryString(queryString: string): Record<string, string> {
const params: Record<string, string> = {};
if (!queryString) {
return params;
}
// Remove leading '?' if present
if (queryString.startsWith('?')) {
queryString = queryString.slice(1);
}
const pairs = queryString.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=');
if (key) {
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
}
}
return params;
}
/**
* Build query string from params
*/
static buildQueryString(params: Record<string, string>): string {
const pairs: string[] = [];
for (const [key, value] of Object.entries(params)) {
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
return pairs.length > 0 ? '?' + pairs.join('&') : '';
}
}

View File

@@ -0,0 +1,70 @@
/**
* HTTP Protocol Type Definitions
*/
import type { THttpMethod, THttpVersion, HttpStatus } from './constants.js';
/**
* HTTP request line structure
*/
export interface IHttpRequestLine {
method: THttpMethod;
path: string;
version: THttpVersion;
}
/**
* HTTP response line structure
*/
export interface IHttpResponseLine {
version: THttpVersion;
status: HttpStatus;
statusText: string;
}
/**
* HTTP header structure
*/
export interface IHttpHeader {
name: string;
value: string;
}
/**
* HTTP message structure (base for request and response)
*/
export interface IHttpMessage {
headers: Record<string, string>;
body?: Buffer;
}
/**
* HTTP request structure
*/
export interface IHttpRequest extends IHttpMessage {
method: THttpMethod;
path: string;
version: THttpVersion;
query?: Record<string, string>;
}
/**
* HTTP response structure
*/
export interface IHttpResponse extends IHttpMessage {
status: HttpStatus;
statusText: string;
version: THttpVersion;
}
/**
* Parsed URL structure
*/
export interface IParsedUrl {
protocol?: string;
hostname?: string;
port?: number;
path?: string;
query?: string;
fragment?: string;
}

11
ts/protocols/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Protocol-specific modules for smartproxy
*
* This directory contains generic protocol knowledge separated from
* smartproxy-specific implementation details.
*/
export * as tls from './tls/index.js';
export * as http from './http/index.js';
export * as proxy from './proxy/index.js';
export * as websocket from './websocket/index.js';

View File

@@ -0,0 +1,7 @@
/**
* PROXY Protocol Module
* HAProxy PROXY protocol implementation
*/
export * from './types.js';
export * from './parser.js';

View File

@@ -0,0 +1,183 @@
/**
* PROXY Protocol Parser
* Implementation of HAProxy PROXY protocol v1 (text format)
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
*/
import type { IProxyInfo, IProxyParseResult, TProxyProtocol } from './types.js';
/**
* PROXY protocol parser
*/
export class ProxyProtocolParser {
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
static readonly HEADER_TERMINATOR = '\r\n';
/**
* Parse PROXY protocol v1 header from buffer
* Returns proxy info and remaining data after header
*/
static parse(data: Buffer): IProxyParseResult {
// Check if buffer starts with PROXY signature
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
return {
proxyInfo: null,
remainingData: data
};
}
// Find header terminator
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
if (headerEndIndex === -1) {
// Header incomplete, need more data
if (data.length > this.MAX_HEADER_LENGTH) {
// Header too long, invalid
throw new Error('PROXY protocol header exceeds maximum length');
}
return {
proxyInfo: null,
remainingData: data
};
}
// Extract header line
const headerLine = data.toString('ascii', 0, headerEndIndex);
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
// Parse header
const parts = headerLine.split(' ');
if (parts.length < 2) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [signature, protocol] = parts;
// Validate protocol
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
throw new Error(`Invalid PROXY protocol: ${protocol}`);
}
// For UNKNOWN protocol, ignore addresses
if (protocol === 'UNKNOWN') {
return {
proxyInfo: {
protocol: 'UNKNOWN',
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
},
remainingData
};
}
// For TCP4/TCP6, we need all 6 parts
if (parts.length !== 6) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
// Validate and parse ports
const sourcePort = parseInt(srcPort, 10);
const destinationPort = parseInt(dstPort, 10);
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
throw new Error(`Invalid source port: ${srcPort}`);
}
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
throw new Error(`Invalid destination port: ${dstPort}`);
}
// Validate IP addresses
const protocolType = protocol as TProxyProtocol;
if (!this.isValidIP(srcIP, protocolType)) {
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
}
if (!this.isValidIP(dstIP, protocolType)) {
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
}
return {
proxyInfo: {
protocol: protocolType,
sourceIP: srcIP,
sourcePort,
destinationIP: dstIP,
destinationPort
},
remainingData
};
}
/**
* Generate PROXY protocol v1 header
*/
static generate(info: IProxyInfo): Buffer {
if (info.protocol === 'UNKNOWN') {
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
}
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
if (header.length > this.MAX_HEADER_LENGTH) {
throw new Error('Generated PROXY protocol header exceeds maximum length');
}
return Buffer.from(header, 'ascii');
}
/**
* Validate IP address format
*/
static isValidIP(ip: string, protocol: TProxyProtocol): boolean {
if (protocol === 'TCP4') {
return this.isIPv4(ip);
} else if (protocol === 'TCP6') {
return this.isIPv6(ip);
}
return false;
}
/**
* Check if string is valid IPv4
*/
static isIPv4(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
const num = parseInt(part, 10);
if (isNaN(num) || num < 0 || num > 255 || part !== num.toString()) {
return false;
}
}
return true;
}
/**
* Check if string is valid IPv6
*/
static isIPv6(ip: string): boolean {
// Basic IPv6 validation
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
return ipv6Regex.test(ip);
}
/**
* Create a connection ID string for tracking
*/
static createConnectionId(connectionInfo: {
sourceIp?: string;
sourcePort?: number;
destIp?: string;
destPort?: number;
}): string {
const { sourceIp, sourcePort, destIp, destPort } = connectionInfo;
return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
}
}

View File

@@ -0,0 +1,53 @@
/**
* PROXY Protocol Type Definitions
* Based on HAProxy PROXY protocol specification
*/
/**
* PROXY protocol version
*/
export type TProxyProtocolVersion = 'v1' | 'v2';
/**
* Connection protocol type
*/
export type TProxyProtocol = 'TCP4' | 'TCP6' | 'UNKNOWN';
/**
* Interface representing parsed PROXY protocol information
*/
export interface IProxyInfo {
protocol: TProxyProtocol;
sourceIP: string;
sourcePort: number;
destinationIP: string;
destinationPort: number;
}
/**
* Interface for parse result including remaining data
*/
export interface IProxyParseResult {
proxyInfo: IProxyInfo | null;
remainingData: Buffer;
}
/**
* PROXY protocol v2 header format
*/
export interface IProxyV2Header {
signature: Buffer;
versionCommand: number;
family: number;
length: number;
}
/**
* Connection information for PROXY protocol
*/
export interface IProxyConnectionInfo {
sourceIp?: string;
sourcePort?: number;
destIp?: string;
destPort?: number;
}

View File

@@ -1,4 +1,4 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../../plugins.js';
import { TlsAlertLevel, TlsAlertDescription, TlsVersion } from '../utils/tls-utils.js'; import { TlsAlertLevel, TlsAlertDescription, TlsVersion } from '../utils/tls-utils.js';
/** /**

37
ts/protocols/tls/index.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* TLS Protocol Module
* Contains generic TLS protocol knowledge including parsers, constants, and utilities
*/
// Export all sub-modules
export * from './alerts/index.js';
export * from './sni/index.js';
export * from './utils/index.js';
// Re-export main utilities and types for convenience
export {
TlsUtils,
TlsRecordType,
TlsHandshakeType,
TlsExtensionType,
TlsAlertLevel,
TlsAlertDescription,
TlsVersion
} from './utils/tls-utils.js';
export { TlsAlert } from './alerts/tls-alert.js';
export { ClientHelloParser } from './sni/client-hello-parser.js';
export { SniExtraction } from './sni/sni-extraction.js';
// Export tlsVersionToString helper
export function tlsVersionToString(major: number, minor: number): string | null {
if (major === 0x03) {
switch (minor) {
case 0x00: return 'SSLv3';
case 0x01: return 'TLSv1.0';
case 0x02: return 'TLSv1.1';
case 0x03: return 'TLSv1.2';
case 0x04: return 'TLSv1.3';
}
}
return null;
}

Some files were not shown because too many files have changed in this diff Show More