Compare commits
57 Commits
Author | SHA1 | Date | |
---|---|---|---|
98b7f3ed7f | |||
cb83caeafd | |||
7850a80452 | |||
ef8f583a90 | |||
2bdd6f8c1f | |||
99d28eafd1 | |||
788b444fcc | |||
4225abe3c4 | |||
74fdb58f84 | |||
bffdaffe39 | |||
67a4228518 | |||
681209f2e1 | |||
c415a6c361 | |||
009e3c4f0e | |||
f9c42975dc | |||
feef949afe | |||
8d3b07b1e6 | |||
51fe935f1f | |||
146fac73cf | |||
4465cac807 | |||
9d7ed21cba | |||
54fbe5beac | |||
0704853fa2 | |||
8cf22ee38b | |||
f28e68e487 | |||
499aed19f6 | |||
618b6fe2d1 | |||
d6027c11c1 | |||
bbdea52677 | |||
d8585975a8 | |||
98c61cccbb | |||
b3dcc0ae22 | |||
b96d7dec98 | |||
0d0a1c740b | |||
9bd87b8437 | |||
0e281b3243 | |||
a14b7802c4 | |||
138900ca8b | |||
cb6c2503e2 | |||
f3fd903231 | |||
0e605d9a9d | |||
1718a3b2f2 | |||
568f77e65b | |||
e212dacbf3 | |||
eea8942670 | |||
0574331b91 | |||
06e6c2eb52 | |||
edd9db31c2 | |||
d4251b2cf9 | |||
4ccc1db8a2 | |||
7e3ed93bc9 | |||
fa793f2c4a | |||
fe8106f0c8 | |||
b317ab8b3a | |||
4fd5524a0f | |||
2013d03ac6 | |||
0e888c5add |
199
changelog.md
199
changelog.md
@ -1,5 +1,204 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.30.8 - fix(core)
|
||||||
|
No changes in this commit.
|
||||||
|
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.30.7 - fix(PortProxy)
|
||||||
|
Improve TLS renegotiation SNI handling by first checking if the new SNI is allowed under the existing domain config. If not, attempt to find an alternative domain config and update the locked domain accordingly; otherwise, terminate the connection on SNI mismatch.
|
||||||
|
|
||||||
|
- Added a preliminary check against the original domain config to allow re-handshakes if the new SNI matches allowed patterns.
|
||||||
|
- If the original config does not allow, search for an alternative domain config and validate IP rules.
|
||||||
|
- Update the locked domain when allowed, ensuring connection reuse with valid certificate context.
|
||||||
|
- Terminate the connection if no suitable domain config is found or IP restrictions are violated.
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.30.6 - fix(PortProxy)
|
||||||
|
Improve TLS renegotiation handling in PortProxy by validating the new SNI against allowed domain configurations. If the new SNI is permitted based on existing IP rules, update the locked domain to allow connection reuse; otherwise, terminate the connection to prevent misrouting.
|
||||||
|
|
||||||
|
- Added logic to check if a new SNI during renegotiation is allowed by comparing IP rules from the matching domain configuration.
|
||||||
|
- Updated detailed logging to indicate when a valid SNI change is accepted and when it results in a mismatch termination.
|
||||||
|
|
||||||
|
## 2025-03-10 - 3.30.5 - fix(internal)
|
||||||
|
No uncommitted changes detected; project files and tests remain unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
## 2025-03-10 - 3.30.4 - fix(PortProxy)
|
||||||
|
Fix TLS renegotiation handling and adjust TLS keep-alive timeouts in PortProxy implementation
|
||||||
|
|
||||||
|
- Allow TLS renegotiation data without an explicit SNI extraction to pass through, ensuring valid renegotiations are not dropped (critical for Chrome).
|
||||||
|
- Update TLS keep-alive timeout from an aggressive 30 minutes to a more generous 4 hours to reduce unnecessary reconnections.
|
||||||
|
- Increase inactivity thresholds for TLS connections from 20 minutes to 2 hours with an additional verification interval extended from 5 to 15 minutes.
|
||||||
|
- Adjust long-lived TLS connection timeout from 45 minutes to 8 hours for improved certificate context refresh in chained proxy scenarios.
|
||||||
|
|
||||||
|
## 2025-03-10 - 3.30.3 - fix(classes.portproxy.ts)
|
||||||
|
Simplify timeout management in PortProxy and fix chained proxy certificate refresh issues
|
||||||
|
|
||||||
|
- Reduced TLS keep-alive timeout from 8 hours to 30 minutes to ensure frequent certificate refresh
|
||||||
|
- Added aggressive TLS state refresh after 20 minutes of inactivity and secondary verification checks
|
||||||
|
- Lowered long-lived TLS connection lifetime from 12 hours to 45 minutes to prevent stale certificates
|
||||||
|
- Removed configurable timeout settings from the public API in favor of hardcoded sensible defaults
|
||||||
|
- Simplified internal timeout management to reduce code complexity and improve certificate handling in chained proxies
|
||||||
|
|
||||||
|
## 2025-03-10 - 3.31.0 - fix(classes.portproxy.ts)
|
||||||
|
Simplified timeout management and fixed certificate issues in chained proxy scenarios
|
||||||
|
|
||||||
|
- Dramatically reduced TLS keep-alive timeout from 8 hours to 30 minutes to ensure fresh certificates
|
||||||
|
- Added aggressive certificate refresh after 20 minutes of inactivity (down from 4 hours)
|
||||||
|
- Added secondary verification checks for TLS refresh operations
|
||||||
|
- Reduced long-lived TLS connection lifetime from 12 hours to 45 minutes
|
||||||
|
- Removed configurable timeouts completely from the public API in favor of hardcoded sensible defaults
|
||||||
|
- Simplified interface by removing no-longer-configurable settings while maintaining internal compatibility
|
||||||
|
- Reduced overall code complexity by eliminating complex timeout management
|
||||||
|
- Fixed chained proxy certificate issues by ensuring more frequent certificate refreshes in all deployment scenarios
|
||||||
|
|
||||||
|
## 2025-03-10 - 3.30.2 - fix(classes.portproxy.ts)
|
||||||
|
Adjust TLS keep-alive timeout to refresh certificate context.
|
||||||
|
|
||||||
|
- Modified TLS keep-alive timeout for connections to 8 hours to refresh certificate context.
|
||||||
|
- Updated timeout log messages for clarity on TLS certificate refresh.
|
||||||
|
|
||||||
|
## 2025-03-10 - 3.30.1 - fix(PortProxy)
|
||||||
|
Improve TLS keep-alive management and fix whitespace formatting
|
||||||
|
|
||||||
|
- Implemented better handling for TLS keep-alive connections after sleep or long inactivity.
|
||||||
|
- Reformatted whitespace for better readability and consistency.
|
||||||
|
|
||||||
|
## 2025-03-08 - 3.30.0 - feat(PortProxy)
|
||||||
|
Add advanced TLS keep-alive handling and system sleep detection
|
||||||
|
|
||||||
|
- Implemented system sleep detection to maintain keep-alive connections.
|
||||||
|
- Enhanced TLS keep-alive connections with extended timeout and sleep detection mechanisms.
|
||||||
|
- Introduced automatic TLS state refresh after system wake-up to prevent connection drops.
|
||||||
|
|
||||||
|
## 2025-03-07 - 3.29.3 - fix(core)
|
||||||
|
Fix functional errors in the proxy setup and enhance pnpm configuration
|
||||||
|
|
||||||
|
- Corrected pnpm configuration to include specific dependencies as 'onlyBuiltDependencies'.
|
||||||
|
|
||||||
|
## 2025-03-07 - 3.29.2 - fix(PortProxy)
|
||||||
|
Fix test for PortProxy handling of custom IPs in Docker/CI environments.
|
||||||
|
|
||||||
|
- Ensure compatibility with Docker/CI environments by standardizing on 127.0.0.1 for test server setup.
|
||||||
|
- Simplify test configuration by using a unique port rather than different IPs.
|
||||||
|
|
||||||
|
## 2025-03-07 - 3.29.1 - fix(readme)
|
||||||
|
Update readme for IPTablesProxy options
|
||||||
|
|
||||||
|
- Add comprehensive examples for IPTablesProxy usage.
|
||||||
|
- Expand IPTablesProxy settings with IPv6, logging, and advanced features.
|
||||||
|
- Clarify option defaults and descriptions for IPTablesProxy.
|
||||||
|
- Enhance 'Troubleshooting' section with IPTables tips.
|
||||||
|
|
||||||
|
## 2025-03-07 - 3.29.0 - feat(IPTablesProxy)
|
||||||
|
Enhanced IPTablesProxy with multi-port and IPv6 support
|
||||||
|
|
||||||
|
- Added support for specifying multiple ports and port ranges, allowing for more complex network proxy configurations.
|
||||||
|
- Introduced IPv6 support to allow handling of IPv6 addressed networks.
|
||||||
|
- Implemented more detailed logging and error handling features to improve debugging capabilities.
|
||||||
|
- Enhanced integration options with NetworkProxy, allowing for a more seamless routing and termination process.
|
||||||
|
- Restructured the initialization and validation process to ensure robust handling of configuration settings.
|
||||||
|
|
||||||
|
## 2025-03-07 - 3.28.6 - fix(PortProxy)
|
||||||
|
Adjust default timeout settings and enhance keep-alive connection handling in PortProxy.
|
||||||
|
|
||||||
|
- Updated default value for maxConnectionLifetime to 24 hours and inactivityTimeout to 4 hours.
|
||||||
|
- Introduced enhanced settings for treating keep-alive connections as 'extended' or 'immortal'.
|
||||||
|
- Modified logic to avoid closing keep-alive connections unnecessarily by adding inactivity warnings and grace periods.
|
||||||
|
|
||||||
|
## 2025-03-07 - 3.28.5 - fix(core)
|
||||||
|
Ensure proper resource cleanup during server shutdown.
|
||||||
|
|
||||||
|
- Fixed potential hanging of server shutdown due to improper cleanup in promise handling.
|
||||||
|
- Corrected potential memory leaks by ensuring all pending and active connections are properly closed during shutdown.
|
||||||
|
|
||||||
|
## 2025-03-07 - 3.28.4 - fix(router)
|
||||||
|
Improve path pattern matching and hostname prioritization in router
|
||||||
|
|
||||||
|
- Enhance path pattern matching capabilities
|
||||||
|
- Ensure hostname prioritization in routing logic
|
||||||
|
|
||||||
|
## 2025-03-06 - 3.28.3 - fix(PortProxy)
|
||||||
|
Ensure timeout values are within Node.js safe limits
|
||||||
|
|
||||||
|
- Implemented `ensureSafeTimeout` to keep timeout values under the maximum safe integer for Node.js.
|
||||||
|
- Updated timeout configurations in `PortProxy` to include safety checks.
|
||||||
|
|
||||||
|
## 2025-03-06 - 3.28.2 - fix(portproxy)
|
||||||
|
Adjust safe timeout defaults in PortProxy to prevent overflow issues.
|
||||||
|
|
||||||
|
- Adjusted socketTimeout to maximum safe limit (~24.8 days) for PortProxy.
|
||||||
|
- Adjusted maxConnectionLifetime to maximum safe limit (~24.8 days) for PortProxy.
|
||||||
|
- Ensured enhanced default timeout settings in PortProxy.
|
||||||
|
|
||||||
|
## 2025-03-06 - 3.28.1 - fix(PortProxy)
|
||||||
|
Improved code formatting and readability in PortProxy class by adjusting spacing and comments.
|
||||||
|
|
||||||
|
- Adjusted comment and spacing for better code readability.
|
||||||
|
- No functional changes made in the PortProxy class.
|
||||||
|
|
||||||
|
## 2025-03-06 - 3.28.0 - feat(router)
|
||||||
|
Add detailed routing tests and refactor ProxyRouter for improved path matching
|
||||||
|
|
||||||
|
- Implemented a comprehensive test suite for the ProxyRouter class to ensure accurate routing based on hostnames and path patterns.
|
||||||
|
- Refactored the ProxyRouter to enhance path matching logic with improvements in wildcard and parameter handling.
|
||||||
|
- Improved logging capabilities within the ProxyRouter for enhanced debugging and info level insights.
|
||||||
|
- Optimized the data structures for storing and accessing proxy configurations to reduce overhead in routing operations.
|
||||||
|
|
||||||
|
## 2025-03-06 - 3.27.0 - feat(AcmeCertManager)
|
||||||
|
Introduce AcmeCertManager for enhanced ACME certificate management
|
||||||
|
|
||||||
|
- Refactored the existing Port80Handler to AcmeCertManager.
|
||||||
|
- Added event-driven certificate management with CertManagerEvents.
|
||||||
|
- Introduced options for configuration such as renew thresholds and production mode.
|
||||||
|
- Implemented certificate renewal checks and logging improvements.
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.26.0 - feat(readme)
|
||||||
|
Updated README with enhanced TLS handling, connection management, and troubleshooting sections.
|
||||||
|
|
||||||
|
- Added details on enhanced TLS handling and browser compatibility improvements.
|
||||||
|
- Included advanced connection management features like random timeout prevention.
|
||||||
|
- Provided comprehensive troubleshooting tips for browser certificate errors and connection stability.
|
||||||
|
- Clarified default configuration options and optimization settings for PortProxy.
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.25.4 - fix(portproxy)
|
||||||
|
Improve connection timeouts and detailed logging for PortProxy
|
||||||
|
|
||||||
|
- Refactored timeout management for connections to include enhanced defaults and prevent thundering herd.
|
||||||
|
- Improved support for TLS handshake detection with logging capabilities in PortProxy.
|
||||||
|
- Removed protocol-specific handling which is now managed generically.
|
||||||
|
- Introduced enhanced logging for SNI extraction and connection management.
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.25.3 - fix(core)
|
||||||
|
Update dependencies and configuration improvements.
|
||||||
|
|
||||||
|
- Upgrade TypeScript version to 5.8.2 for better compatibility.
|
||||||
|
- Ensure all proxy and server tests pass with updated configurations.
|
||||||
|
- Improve logging for better traceability in proxy operations.
|
||||||
|
- Add handlers for WebSockets and HTTPS improvements.
|
||||||
|
- Fix various issues related to proxy timeout and connection handling.
|
||||||
|
- Update test certificates validation for better test coverage.
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.25.2 - fix(PortProxy)
|
||||||
|
Adjust timeout settings and handle inactivity properly in PortProxy.
|
||||||
|
|
||||||
|
- Changed initialDataTimeout default to 30 seconds for better handling of initial data reception.
|
||||||
|
- Adjusted keepAliveInitialDelay to 30 seconds for consistent socket optimization.
|
||||||
|
- Introduced proper inactivity handling with updated timeout logic.
|
||||||
|
- Parity check now accounts for a 120-second threshold for outgoing socket closure.
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.25.1 - fix(PortProxy)
|
||||||
|
Adjust inactivity threshold to a random value between 20 and 30 minutes for better variability
|
||||||
|
|
||||||
|
- Modified inactivity threshold calculation within PortProxy to use a random value between 1.2 and 1.8 million milliseconds.
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.25.0 - feat(PortProxy)
|
||||||
|
Enhanced PortProxy with detailed logging, protocol detection, and rate limiting.
|
||||||
|
|
||||||
|
- Added detailed logging capabilities for connection tracking in the PortProxy.
|
||||||
|
- Introduced protocol detection allowing HTTP and WebSocket upgrades.
|
||||||
|
- Implemented rate limiting for connections by IP.
|
||||||
|
- Enhanced timeout handling for various protocol-specific scenarios.
|
||||||
|
|
||||||
## 2025-03-05 - 3.24.0 - feat(core)
|
## 2025-03-05 - 3.24.0 - feat(core)
|
||||||
Enhance core functionalities and test coverage for NetworkProxy and PortProxy
|
Enhance core functionalities and test coverage for NetworkProxy and PortProxy
|
||||||
|
|
||||||
|
11
package.json
11
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "3.24.0",
|
"version": "3.30.8",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
|
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@ -28,7 +28,7 @@
|
|||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^2.0.23",
|
"@push.rocks/smartrequest": "^2.0.23",
|
||||||
"@push.rocks/smartstring": "^4.0.15",
|
"@push.rocks/smartstring": "^4.0.15",
|
||||||
"@tsclass/tsclass": "^4.4.0",
|
"@tsclass/tsclass": "^4.4.3",
|
||||||
"@types/minimatch": "^5.1.2",
|
"@types/minimatch": "^5.1.2",
|
||||||
"@types/ws": "^8.18.0",
|
"@types/ws": "^8.18.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
@ -77,6 +77,11 @@
|
|||||||
"url": "https://code.foss.global/push.rocks/smartproxy/issues"
|
"url": "https://code.foss.global/push.rocks/smartproxy/issues"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {}
|
"overrides": {},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"mongodb-memory-server",
|
||||||
|
"puppeteer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1901
pnpm-lock.yaml
generated
1901
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
214
readme.md
214
readme.md
@ -193,12 +193,14 @@ sequenceDiagram
|
|||||||
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
|
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
|
||||||
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
|
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
|
||||||
- **TCP Port Forwarding** - Advanced port forwarding with SNI inspection and domain-based routing
|
- **TCP Port Forwarding** - Advanced port forwarding with SNI inspection and domain-based routing
|
||||||
|
- **Enhanced TLS Handling** - Robust TLS handshake processing with improved certificate error handling
|
||||||
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
|
- **HTTP to HTTPS Redirection** - Automatically redirect HTTP requests to HTTPS
|
||||||
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
|
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
|
||||||
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
||||||
- **IPTables Integration** - Direct manipulation of iptables for low-level port forwarding
|
- **IPTables Integration** - Direct manipulation of iptables for low-level port forwarding
|
||||||
- **Basic Authentication** - Support for basic auth on proxied routes
|
- **Basic Authentication** - Support for basic auth on proxied routes
|
||||||
- **Connection Management** - Intelligent connection tracking and cleanup
|
- **Connection Management** - Intelligent connection tracking and cleanup with configurable timeouts
|
||||||
|
- **Browser Compatibility** - Optimized for modern browsers with fixes for common TLS handshake issues
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -275,18 +277,38 @@ const portProxy = new PortProxy({
|
|||||||
toPort: 8443,
|
toPort: 8443,
|
||||||
targetIP: 'localhost', // Default target host
|
targetIP: 'localhost', // Default target host
|
||||||
sniEnabled: true, // Enable SNI inspection
|
sniEnabled: true, // Enable SNI inspection
|
||||||
|
|
||||||
|
// Enhanced reliability settings
|
||||||
|
initialDataTimeout: 60000, // 60 seconds for initial TLS handshake
|
||||||
|
socketTimeout: 3600000, // 1 hour socket timeout
|
||||||
|
maxConnectionLifetime: 3600000, // 1 hour connection lifetime
|
||||||
|
inactivityTimeout: 3600000, // 1 hour inactivity timeout
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024, // 10MB buffer for large TLS handshakes
|
||||||
|
|
||||||
|
// Browser compatibility enhancement
|
||||||
|
enableTlsDebugLogging: false, // Enable for troubleshooting TLS issues
|
||||||
|
|
||||||
|
// Port and IP configuration
|
||||||
globalPortRanges: [{ from: 443, to: 443 }],
|
globalPortRanges: [{ from: 443, to: 443 }],
|
||||||
defaultAllowedIPs: ['*'], // Allow all IPs by default
|
defaultAllowedIPs: ['*'], // Allow all IPs by default
|
||||||
|
|
||||||
|
// Socket optimizations for better connection stability
|
||||||
|
noDelay: true, // Disable Nagle's algorithm
|
||||||
|
keepAlive: true, // Enable TCP keepalive
|
||||||
|
enableKeepAliveProbes: true, // Enhanced keepalive for stability
|
||||||
|
|
||||||
|
// Domain-specific routing configuration
|
||||||
domainConfigs: [
|
domainConfigs: [
|
||||||
{
|
{
|
||||||
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
|
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
|
||||||
allowedIPs: ['192.168.1.*'], // Restrict access by IP
|
allowedIPs: ['192.168.1.*'], // Restrict access by IP
|
||||||
blockedIPs: ['192.168.1.100'], // Block specific IPs
|
blockedIPs: ['192.168.1.100'], // Block specific IPs
|
||||||
targetIPs: ['10.0.0.1', '10.0.0.2'], // Round-robin between multiple targets
|
targetIPs: ['10.0.0.1', '10.0.0.2'], // Round-robin between multiple targets
|
||||||
portRanges: [{ from: 443, to: 443 }]
|
portRanges: [{ from: 443, to: 443 }],
|
||||||
|
connectionTimeout: 7200000 // Domain-specific timeout (2 hours)
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
maxConnectionLifetime: 3600000, // 1 hour in milliseconds
|
|
||||||
preserveSourceIP: true
|
preserveSourceIP: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -298,8 +320,8 @@ portProxy.start();
|
|||||||
```typescript
|
```typescript
|
||||||
import { IPTablesProxy } from '@push.rocks/smartproxy';
|
import { IPTablesProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Configure IPTables to forward from port 80 to 8080
|
// Basic usage - forward single port
|
||||||
const iptables = new IPTablesProxy({
|
const basicProxy = new IPTablesProxy({
|
||||||
fromPort: 80,
|
fromPort: 80,
|
||||||
toPort: 8080,
|
toPort: 8080,
|
||||||
toHost: 'localhost',
|
toHost: 'localhost',
|
||||||
@ -307,7 +329,38 @@ const iptables = new IPTablesProxy({
|
|||||||
deleteOnExit: true // Automatically clean up rules on process exit
|
deleteOnExit: true // Automatically clean up rules on process exit
|
||||||
});
|
});
|
||||||
|
|
||||||
iptables.start();
|
// Forward port ranges
|
||||||
|
const rangeProxy = new IPTablesProxy({
|
||||||
|
fromPort: { from: 3000, to: 3010 }, // Forward ports 3000-3010
|
||||||
|
toPort: { from: 8000, to: 8010 }, // To ports 8000-8010
|
||||||
|
protocol: 'tcp', // TCP protocol (default)
|
||||||
|
ipv6Support: true, // Enable IPv6 support
|
||||||
|
enableLogging: true // Enable detailed logging
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multiple port specifications with IP filtering
|
||||||
|
const advancedProxy = new IPTablesProxy({
|
||||||
|
fromPort: [80, 443, { from: 8000, to: 8010 }], // Multiple ports/ranges
|
||||||
|
toPort: [8080, 8443, { from: 18000, to: 18010 }],
|
||||||
|
allowedSourceIPs: ['10.0.0.0/8', '192.168.1.0/24'], // Only allow these IPs
|
||||||
|
bannedSourceIPs: ['192.168.1.100'], // Explicitly block these IPs
|
||||||
|
addJumpRule: true, // Use custom chain for better management
|
||||||
|
checkExistingRules: true // Check for duplicate rules
|
||||||
|
});
|
||||||
|
|
||||||
|
// NetworkProxy integration for SSL termination
|
||||||
|
const sslProxy = new IPTablesProxy({
|
||||||
|
fromPort: 443,
|
||||||
|
toPort: 8443,
|
||||||
|
netProxyIntegration: {
|
||||||
|
enabled: true,
|
||||||
|
redirectLocalhost: true, // Redirect localhost traffic to NetworkProxy
|
||||||
|
sslTerminationPort: 8443 // Port where NetworkProxy handles SSL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start any of the proxies
|
||||||
|
await basicProxy.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Automatic HTTPS Certificate Management
|
### Automatic HTTPS Certificate Management
|
||||||
@ -333,40 +386,92 @@ acmeHandler.addDomain('api.example.com');
|
|||||||
|
|
||||||
### PortProxy Settings
|
### PortProxy Settings
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|--------------------------|--------------------------------------------------------|-------------|
|
|---------------------------|--------------------------------------------------------|-------------|
|
||||||
| `fromPort` | Port to listen on | - |
|
| `fromPort` | Port to listen on | - |
|
||||||
| `toPort` | Destination port to forward to | - |
|
| `toPort` | Destination port to forward to | - |
|
||||||
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
|
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
|
||||||
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
|
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
|
||||||
| `defaultAllowedIPs` | IP patterns allowed by default | - |
|
| `defaultAllowedIPs` | IP patterns allowed by default | - |
|
||||||
| `defaultBlockedIPs` | IP patterns blocked by default | - |
|
| `defaultBlockedIPs` | IP patterns blocked by default | - |
|
||||||
| `preserveSourceIP` | Preserve the original client IP | false |
|
| `preserveSourceIP` | Preserve the original client IP | false |
|
||||||
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 600000 |
|
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 3600000 |
|
||||||
| `globalPortRanges` | Array of port ranges to listen on | - |
|
| `initialDataTimeout` | Timeout for initial data/handshake in ms | 60000 |
|
||||||
| `forwardAllGlobalRanges` | Forward all global range connections to targetIP | false |
|
| `socketTimeout` | Socket inactivity timeout in ms | 3600000 |
|
||||||
| `gracefulShutdownTimeout`| Time in ms to wait during shutdown | 30000 |
|
| `inactivityTimeout` | Connection inactivity check timeout in ms | 3600000 |
|
||||||
|
| `inactivityCheckInterval` | How often to check for inactive connections in ms | 60000 |
|
||||||
|
| `maxPendingDataSize` | Maximum bytes to buffer during connection setup | 10485760 |
|
||||||
|
| `globalPortRanges` | Array of port ranges to listen on | - |
|
||||||
|
| `forwardAllGlobalRanges` | Forward all global range connections to targetIP | false |
|
||||||
|
| `gracefulShutdownTimeout` | Time in ms to wait during shutdown | 30000 |
|
||||||
|
| `noDelay` | Disable Nagle's algorithm | true |
|
||||||
|
| `keepAlive` | Enable TCP keepalive | true |
|
||||||
|
| `keepAliveInitialDelay` | Initial delay before sending keepalive probes in ms | 30000 |
|
||||||
|
| `enableKeepAliveProbes` | Enable enhanced TCP keep-alive probes | false |
|
||||||
|
| `enableTlsDebugLogging` | Enable detailed TLS handshake debugging | false |
|
||||||
|
| `enableDetailedLogging` | Enable detailed connection logging | false |
|
||||||
|
| `enableRandomizedTimeouts`| Randomize timeouts slightly to prevent thundering herd | true |
|
||||||
|
|
||||||
### IPTablesProxy Settings
|
### IPTablesProxy Settings
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|-------------------|---------------------------------------------|-------------|
|
|-----------------------|---------------------------------------------------|-------------|
|
||||||
| `fromPort` | Source port to forward from | - |
|
| `fromPort` | Source port(s) or range(s) to forward from | - |
|
||||||
| `toPort` | Destination port to forward to | - |
|
| `toPort` | Destination port(s) or range(s) to forward to | - |
|
||||||
| `toHost` | Destination host to forward to | 'localhost' |
|
| `toHost` | Destination host to forward to | 'localhost' |
|
||||||
| `preserveSourceIP`| Preserve the original client IP | false |
|
| `preserveSourceIP` | Preserve the original client IP | false |
|
||||||
| `deleteOnExit` | Remove iptables rules when process exits | false |
|
| `deleteOnExit` | Remove iptables rules when process exits | false |
|
||||||
|
| `protocol` | Protocol to forward ('tcp', 'udp', or 'all') | 'tcp' |
|
||||||
|
| `enableLogging` | Enable detailed logging | false |
|
||||||
|
| `ipv6Support` | Enable IPv6 support with ip6tables | false |
|
||||||
|
| `allowedSourceIPs` | Array of IP addresses/CIDR allowed to connect | - |
|
||||||
|
| `bannedSourceIPs` | Array of IP addresses/CIDR blocked from connecting | - |
|
||||||
|
| `forceCleanSlate` | Clear all IPTablesProxy rules before starting | false |
|
||||||
|
| `addJumpRule` | Add a custom chain for cleaner rule management | false |
|
||||||
|
| `checkExistingRules` | Check if rules already exist before adding | true |
|
||||||
|
| `netProxyIntegration` | NetworkProxy integration options (object) | - |
|
||||||
|
|
||||||
|
#### IPTablesProxy NetworkProxy Integration Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|----------------------|---------------------------------------------------|---------|
|
||||||
|
| `enabled` | Enable NetworkProxy integration | false |
|
||||||
|
| `redirectLocalhost` | Redirect localhost traffic to NetworkProxy | false |
|
||||||
|
| `sslTerminationPort` | Port where NetworkProxy handles SSL termination | - |
|
||||||
|
|
||||||
## Advanced Features
|
## Advanced Features
|
||||||
|
|
||||||
|
### TLS Handshake Optimization
|
||||||
|
|
||||||
|
The enhanced `PortProxy` implementation includes significant improvements for TLS handshake handling:
|
||||||
|
|
||||||
|
- Robust SNI extraction with improved error handling
|
||||||
|
- Increased buffer size for complex TLS handshakes (10MB)
|
||||||
|
- Longer initial handshake timeout (60 seconds)
|
||||||
|
- Detection and tracking of TLS connection states
|
||||||
|
- Optional detailed TLS debug logging for troubleshooting
|
||||||
|
- Browser compatibility fixes for Chrome certificate errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example configuration to solve Chrome certificate errors
|
||||||
|
const portProxy = new PortProxy({
|
||||||
|
// ... other settings
|
||||||
|
initialDataTimeout: 60000, // Give browser more time for handshake
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024, // Larger buffer for complex handshakes
|
||||||
|
enableTlsDebugLogging: true, // Enable when troubleshooting
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### Connection Management and Monitoring
|
### Connection Management and Monitoring
|
||||||
|
|
||||||
The `PortProxy` class includes built-in connection tracking and monitoring:
|
The `PortProxy` class includes built-in connection tracking and monitoring:
|
||||||
|
|
||||||
- Automatic cleanup of idle connections
|
- Automatic cleanup of idle connections with configurable timeouts
|
||||||
- Timeouts for connections that exceed maximum lifetime
|
- Timeouts for connections that exceed maximum lifetime
|
||||||
- Detailed logging of connection states
|
- Detailed logging of connection states
|
||||||
- Termination statistics
|
- Termination statistics
|
||||||
|
- Randomized timeouts to prevent "thundering herd" problems
|
||||||
|
- Per-domain timeout configuration
|
||||||
|
|
||||||
### WebSocket Support
|
### WebSocket Support
|
||||||
|
|
||||||
@ -385,6 +490,61 @@ The `PortProxy` class can inspect the SNI (Server Name Indication) field in TLS
|
|||||||
- Domain-specific allowed IP ranges
|
- Domain-specific allowed IP ranges
|
||||||
- Protection against SNI renegotiation attacks
|
- Protection against SNI renegotiation attacks
|
||||||
|
|
||||||
|
### Enhanced IPTables Management
|
||||||
|
|
||||||
|
The improved `IPTablesProxy` class offers advanced capabilities:
|
||||||
|
|
||||||
|
- Support for multiple port ranges and individual ports
|
||||||
|
- IPv6 support with ip6tables
|
||||||
|
- Source IP filtering with allow/block lists
|
||||||
|
- Custom chain creation for better rule organization
|
||||||
|
- NetworkProxy integration for SSL termination
|
||||||
|
- Automatic rule existence checking to prevent duplicates
|
||||||
|
- Comprehensive cleanup on shutdown
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Browser Certificate Errors
|
||||||
|
|
||||||
|
If you experience certificate errors in browsers, especially in Chrome, try these solutions:
|
||||||
|
|
||||||
|
1. **Increase Initial Data Timeout**: Set `initialDataTimeout` to 60 seconds or higher
|
||||||
|
2. **Increase Buffer Size**: Set `maxPendingDataSize` to 10MB or higher
|
||||||
|
3. **Enable TLS Debug Logging**: Set `enableTlsDebugLogging: true` to troubleshoot handshake issues
|
||||||
|
4. **Enable Keep-Alive Probes**: Set `enableKeepAliveProbes: true` for better connection stability
|
||||||
|
5. **Check Certificate Chain**: Ensure your certificate chain is complete and in the correct order
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Configuration to fix Chrome certificate errors
|
||||||
|
const portProxy = new PortProxy({
|
||||||
|
// ... other settings
|
||||||
|
initialDataTimeout: 60000,
|
||||||
|
maxPendingDataSize: 10 * 1024 * 1024,
|
||||||
|
enableTlsDebugLogging: true,
|
||||||
|
enableKeepAliveProbes: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Stability
|
||||||
|
|
||||||
|
For improved connection stability in high-traffic environments:
|
||||||
|
|
||||||
|
1. **Set Appropriate Timeouts**: Use longer timeouts for long-lived connections
|
||||||
|
2. **Use Domain-Specific Timeouts**: Configure per-domain timeouts for different types of services
|
||||||
|
3. **Enable TCP Keep-Alive**: Ensure `keepAlive` is set to `true`
|
||||||
|
4. **Monitor Connection Statistics**: Enable detailed logging to track termination reasons
|
||||||
|
5. **Fine-tune Inactivity Checks**: Adjust `inactivityCheckInterval` based on your traffic patterns
|
||||||
|
|
||||||
|
### IPTables Troubleshooting
|
||||||
|
|
||||||
|
If you're experiencing issues with IPTablesProxy:
|
||||||
|
|
||||||
|
1. **Enable Detailed Logging**: Set `enableLogging: true` to see all rule operations
|
||||||
|
2. **Force Clean Slate**: Use `forceCleanSlate: true` to remove any lingering rules
|
||||||
|
3. **Use Custom Chains**: Enable `addJumpRule: true` for cleaner rule management
|
||||||
|
4. **Check Permissions**: Ensure your process has sufficient permissions to modify iptables
|
||||||
|
5. **Verify IPv6 Support**: If using `ipv6Support: true`, ensure ip6tables is available
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||||
|
@ -113,20 +113,21 @@ tap.test('should forward TCP connections to custom host', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test custom IP forwarding
|
// Test custom IP forwarding
|
||||||
// SIMPLIFIED: This version avoids port ranges and domain configs to prevent loops
|
// Modified to work in Docker/CI environments without needing 127.0.0.2
|
||||||
tap.test('should forward connections to custom IP', async () => {
|
tap.test('should forward connections to custom IP', async () => {
|
||||||
// Set up ports that are FAR apart to avoid any possible confusion
|
// Set up ports that are FAR apart to avoid any possible confusion
|
||||||
const forcedProxyPort = PROXY_PORT + 2; // 4003 - The port that our proxy listens on
|
const forcedProxyPort = PROXY_PORT + 2; // 4003 - The port that our proxy listens on
|
||||||
const targetServerPort = TEST_SERVER_PORT + 200; // 4200 - Target test server on another IP
|
const targetServerPort = TEST_SERVER_PORT + 200; // 4200 - Target test server on different port
|
||||||
|
|
||||||
// Create a test server listening on 127.0.0.2:4200
|
// Create a test server listening on a unique port on 127.0.0.1 (works in all environments)
|
||||||
const testServer2 = await createTestServer(targetServerPort, '127.0.0.2');
|
const testServer2 = await createTestServer(targetServerPort, '127.0.0.1');
|
||||||
|
|
||||||
// Simplify the test drastically - use ONE proxy with very explicit configuration
|
// We're simulating routing to a different IP by using a different port
|
||||||
|
// This tests the core functionality without requiring multiple IPs
|
||||||
const domainProxy = new PortProxy({
|
const domainProxy = new PortProxy({
|
||||||
fromPort: forcedProxyPort, // 4003 - Listen on this port
|
fromPort: forcedProxyPort, // 4003 - Listen on this port
|
||||||
toPort: targetServerPort, // 4200 - Default forwarding port - MUST BE DIFFERENT from fromPort
|
toPort: targetServerPort, // 4200 - Forward to this port
|
||||||
targetIP: '127.0.0.2', // Forward to IP where test server is
|
targetIP: '127.0.0.1', // Always use localhost (works in Docker)
|
||||||
domainConfigs: [], // No domain configs to confuse things
|
domainConfigs: [], // No domain configs to confuse things
|
||||||
sniEnabled: false,
|
sniEnabled: false,
|
||||||
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], // Allow localhost
|
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], // Allow localhost
|
||||||
|
346
test/test.router.ts
Normal file
346
test/test.router.ts
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import { expect, tap } from '@push.rocks/tapbundle';
|
||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { ProxyRouter, type IRouterResult } from '../ts/classes.router.js';
|
||||||
|
|
||||||
|
// Test proxies and configurations
|
||||||
|
let router: ProxyRouter;
|
||||||
|
|
||||||
|
// Sample hostname for testing
|
||||||
|
const TEST_DOMAIN = 'example.com';
|
||||||
|
const TEST_SUBDOMAIN = 'api.example.com';
|
||||||
|
const TEST_WILDCARD = '*.example.com';
|
||||||
|
|
||||||
|
// Helper: Creates a mock HTTP request for testing
|
||||||
|
function createMockRequest(host: string, url: string = '/'): http.IncomingMessage {
|
||||||
|
const req = {
|
||||||
|
headers: { host },
|
||||||
|
url,
|
||||||
|
socket: {
|
||||||
|
remoteAddress: '127.0.0.1'
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Creates a test proxy configuration
|
||||||
|
function createProxyConfig(
|
||||||
|
hostname: string,
|
||||||
|
destinationIp: string = '10.0.0.1',
|
||||||
|
destinationPort: number = 8080
|
||||||
|
): tsclass.network.IReverseProxyConfig {
|
||||||
|
return {
|
||||||
|
hostName: hostname,
|
||||||
|
destinationIp,
|
||||||
|
destinationPort: destinationPort.toString(), // Convert to string for IReverseProxyConfig
|
||||||
|
publicKey: 'mock-cert',
|
||||||
|
privateKey: 'mock-key'
|
||||||
|
} as tsclass.network.IReverseProxyConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SETUP: Create a ProxyRouter instance
|
||||||
|
tap.test('setup proxy router test environment', async () => {
|
||||||
|
router = new ProxyRouter();
|
||||||
|
|
||||||
|
// Initialize with empty config
|
||||||
|
router.setNewProxyConfigs([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test basic routing by hostname
|
||||||
|
tap.test('should route requests by hostname', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN);
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling of hostname with port number
|
||||||
|
tap.test('should handle hostname with port number', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest(`${TEST_DOMAIN}:443`);
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test case-insensitive hostname matching
|
||||||
|
tap.test('should perform case-insensitive hostname matching', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN.toLowerCase());
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN.toUpperCase());
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling of unmatched hostnames
|
||||||
|
tap.test('should return undefined for unmatched hostnames', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
const req = createMockRequest('unknown.domain.com');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test adding path patterns
|
||||||
|
tap.test('should match requests using path patterns', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
// Add a path pattern to the config
|
||||||
|
router.setPathPattern(config, '/api/users');
|
||||||
|
|
||||||
|
// Test that path matches
|
||||||
|
const req1 = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||||
|
const result1 = router.routeReqWithDetails(req1);
|
||||||
|
|
||||||
|
expect(result1).toBeTruthy();
|
||||||
|
expect(result1.config).toEqual(config);
|
||||||
|
expect(result1.pathMatch).toEqual('/api/users');
|
||||||
|
|
||||||
|
// Test that non-matching path doesn't match
|
||||||
|
const req2 = createMockRequest(TEST_DOMAIN, '/web/users');
|
||||||
|
const result2 = router.routeReqWithDetails(req2);
|
||||||
|
|
||||||
|
expect(result2).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling wildcard patterns
|
||||||
|
tap.test('should support wildcard path patterns', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
router.setPathPattern(config, '/api/*');
|
||||||
|
|
||||||
|
// Test with path that matches the wildcard pattern
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/api/users/123');
|
||||||
|
const result = router.routeReqWithDetails(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.config).toEqual(config);
|
||||||
|
expect(result.pathMatch).toEqual('/api');
|
||||||
|
|
||||||
|
// Print the actual value to diagnose issues
|
||||||
|
console.log('Path remainder value:', result.pathRemainder);
|
||||||
|
expect(result.pathRemainder).toBeTruthy();
|
||||||
|
expect(result.pathRemainder).toEqual('/users/123');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test extracting path parameters
|
||||||
|
tap.test('should extract path parameters from URL', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
router.setPathPattern(config, '/users/:id/profile');
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/users/123/profile');
|
||||||
|
const result = router.routeReqWithDetails(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.config).toEqual(config);
|
||||||
|
expect(result.pathParams).toBeTruthy();
|
||||||
|
expect(result.pathParams.id).toEqual('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test multiple configs for same hostname with different paths
|
||||||
|
tap.test('should support multiple configs for same hostname with different paths', async () => {
|
||||||
|
const apiConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||||
|
const webConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||||
|
|
||||||
|
// Add both configs
|
||||||
|
router.setNewProxyConfigs([apiConfig, webConfig]);
|
||||||
|
|
||||||
|
// Set different path patterns
|
||||||
|
router.setPathPattern(apiConfig, '/api');
|
||||||
|
router.setPathPattern(webConfig, '/web');
|
||||||
|
|
||||||
|
// Test API path routes to API config
|
||||||
|
const apiReq = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||||
|
const apiResult = router.routeReq(apiReq);
|
||||||
|
|
||||||
|
expect(apiResult).toEqual(apiConfig);
|
||||||
|
|
||||||
|
// Test web path routes to web config
|
||||||
|
const webReq = createMockRequest(TEST_DOMAIN, '/web/dashboard');
|
||||||
|
const webResult = router.routeReq(webReq);
|
||||||
|
|
||||||
|
expect(webResult).toEqual(webConfig);
|
||||||
|
|
||||||
|
// Test unknown path returns undefined
|
||||||
|
const unknownReq = createMockRequest(TEST_DOMAIN, '/unknown');
|
||||||
|
const unknownResult = router.routeReq(unknownReq);
|
||||||
|
|
||||||
|
expect(unknownResult).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test wildcard subdomains
|
||||||
|
tap.test('should match wildcard subdomains', async () => {
|
||||||
|
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
|
||||||
|
router.setNewProxyConfigs([wildcardConfig]);
|
||||||
|
|
||||||
|
// Test that subdomain.example.com matches *.example.com
|
||||||
|
const req = createMockRequest('subdomain.example.com');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).toEqual(wildcardConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test default configuration fallback
|
||||||
|
tap.test('should fall back to default configuration', async () => {
|
||||||
|
const defaultConfig = createProxyConfig('*');
|
||||||
|
const specificConfig = createProxyConfig(TEST_DOMAIN);
|
||||||
|
|
||||||
|
router.setNewProxyConfigs([defaultConfig, specificConfig]);
|
||||||
|
|
||||||
|
// Test specific domain routes to specific config
|
||||||
|
const specificReq = createMockRequest(TEST_DOMAIN);
|
||||||
|
const specificResult = router.routeReq(specificReq);
|
||||||
|
|
||||||
|
expect(specificResult).toEqual(specificConfig);
|
||||||
|
|
||||||
|
// Test unknown domain falls back to default config
|
||||||
|
const unknownReq = createMockRequest('unknown.com');
|
||||||
|
const unknownResult = router.routeReq(unknownReq);
|
||||||
|
|
||||||
|
expect(unknownResult).toEqual(defaultConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test priority between exact and wildcard matches
|
||||||
|
tap.test('should prioritize exact hostname over wildcard', async () => {
|
||||||
|
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
|
||||||
|
const exactConfig = createProxyConfig(TEST_SUBDOMAIN);
|
||||||
|
|
||||||
|
router.setNewProxyConfigs([wildcardConfig, exactConfig]);
|
||||||
|
|
||||||
|
// Test that exact match takes priority
|
||||||
|
const req = createMockRequest(TEST_SUBDOMAIN);
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(exactConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test adding and removing configurations
|
||||||
|
tap.test('should manage configurations correctly', async () => {
|
||||||
|
router.setNewProxyConfigs([]);
|
||||||
|
|
||||||
|
// Add a config
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.addProxyConfig(config);
|
||||||
|
|
||||||
|
// Verify routing works
|
||||||
|
const req = createMockRequest(TEST_DOMAIN);
|
||||||
|
let result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(config);
|
||||||
|
|
||||||
|
// Remove the config and verify it no longer routes
|
||||||
|
const removed = router.removeProxyConfig(TEST_DOMAIN);
|
||||||
|
expect(removed).toBeTrue();
|
||||||
|
|
||||||
|
result = router.routeReq(req);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test path pattern specificity
|
||||||
|
tap.test('should prioritize more specific path patterns', async () => {
|
||||||
|
const genericConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
|
||||||
|
const specificConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
|
||||||
|
|
||||||
|
router.setNewProxyConfigs([genericConfig, specificConfig]);
|
||||||
|
|
||||||
|
router.setPathPattern(genericConfig, '/api/*');
|
||||||
|
router.setPathPattern(specificConfig, '/api/users');
|
||||||
|
|
||||||
|
// The more specific '/api/users' should match before the '/api/*' wildcard
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/api/users');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(specificConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test getHostnames method
|
||||||
|
tap.test('should retrieve all configured hostnames', async () => {
|
||||||
|
router.setNewProxyConfigs([
|
||||||
|
createProxyConfig(TEST_DOMAIN),
|
||||||
|
createProxyConfig(TEST_SUBDOMAIN)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hostnames = router.getHostnames();
|
||||||
|
|
||||||
|
expect(hostnames.length).toEqual(2);
|
||||||
|
expect(hostnames).toContain(TEST_DOMAIN.toLowerCase());
|
||||||
|
expect(hostnames).toContain(TEST_SUBDOMAIN.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test handling missing host header
|
||||||
|
tap.test('should handle missing host header', async () => {
|
||||||
|
const defaultConfig = createProxyConfig('*');
|
||||||
|
router.setNewProxyConfigs([defaultConfig]);
|
||||||
|
|
||||||
|
const req = createMockRequest('');
|
||||||
|
req.headers.host = undefined;
|
||||||
|
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(defaultConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test complex path parameters
|
||||||
|
tap.test('should handle complex path parameters', async () => {
|
||||||
|
const config = createProxyConfig(TEST_DOMAIN);
|
||||||
|
router.setNewProxyConfigs([config]);
|
||||||
|
|
||||||
|
router.setPathPattern(config, '/api/:version/users/:userId/posts/:postId');
|
||||||
|
|
||||||
|
const req = createMockRequest(TEST_DOMAIN, '/api/v1/users/123/posts/456');
|
||||||
|
const result = router.routeReqWithDetails(req);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.config).toEqual(config);
|
||||||
|
expect(result.pathParams).toBeTruthy();
|
||||||
|
expect(result.pathParams.version).toEqual('v1');
|
||||||
|
expect(result.pathParams.userId).toEqual('123');
|
||||||
|
expect(result.pathParams.postId).toEqual('456');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Performance test
|
||||||
|
tap.test('should handle many configurations efficiently', async () => {
|
||||||
|
const configs = [];
|
||||||
|
|
||||||
|
// Create many configs with different hostnames
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
configs.push(createProxyConfig(`host-${i}.example.com`));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.setNewProxyConfigs(configs);
|
||||||
|
|
||||||
|
// Test middle of the list to avoid best/worst case
|
||||||
|
const req = createMockRequest('host-50.example.com');
|
||||||
|
const result = router.routeReq(req);
|
||||||
|
|
||||||
|
expect(result).toEqual(configs[50]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test cleanup
|
||||||
|
tap.test('cleanup proxy router test environment', async () => {
|
||||||
|
// Clear all configurations
|
||||||
|
router.setNewProxyConfigs([]);
|
||||||
|
|
||||||
|
// Verify empty state
|
||||||
|
expect(router.getHostnames().length).toEqual(0);
|
||||||
|
expect(router.getProxyConfigs().length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '3.24.0',
|
version: '3.30.8',
|
||||||
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
|
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
|
||||||
}
|
}
|
||||||
|
@ -3,43 +3,100 @@ import { promisify } from 'util';
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a port range for forwarding
|
||||||
|
*/
|
||||||
|
export interface IPortRange {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings for IPTablesProxy.
|
* Settings for IPTablesProxy.
|
||||||
*/
|
*/
|
||||||
export interface IIpTableProxySettings {
|
export interface IIpTableProxySettings {
|
||||||
fromPort: number;
|
// Basic settings
|
||||||
toPort: number;
|
fromPort: number | IPortRange | Array<number | IPortRange>; // Support single port, port range, or multiple ports/ranges
|
||||||
|
toPort: number | IPortRange | Array<number | IPortRange>;
|
||||||
toHost?: string; // Target host for proxying; defaults to 'localhost'
|
toHost?: string; // Target host for proxying; defaults to 'localhost'
|
||||||
preserveSourceIP?: boolean; // If true, the original source IP is preserved.
|
|
||||||
deleteOnExit?: boolean; // If true, clean up marked iptables rules before process exit.
|
// Advanced settings
|
||||||
|
preserveSourceIP?: boolean; // If true, the original source IP is preserved
|
||||||
|
deleteOnExit?: boolean; // If true, clean up marked iptables rules before process exit
|
||||||
|
protocol?: 'tcp' | 'udp' | 'all'; // Protocol to forward, defaults to 'tcp'
|
||||||
|
enableLogging?: boolean; // Enable detailed logging
|
||||||
|
ipv6Support?: boolean; // Enable IPv6 support (ip6tables)
|
||||||
|
|
||||||
|
// Source filtering
|
||||||
|
allowedSourceIPs?: string[]; // If provided, only these IPs are allowed
|
||||||
|
bannedSourceIPs?: string[]; // If provided, these IPs are blocked
|
||||||
|
|
||||||
|
// Rule management
|
||||||
|
forceCleanSlate?: boolean; // Clear all IPTablesProxy rules before starting
|
||||||
|
addJumpRule?: boolean; // Add a custom chain for cleaner rule management
|
||||||
|
checkExistingRules?: boolean; // Check if rules already exist before adding
|
||||||
|
|
||||||
|
// Integration with PortProxy/NetworkProxy
|
||||||
|
netProxyIntegration?: {
|
||||||
|
enabled: boolean;
|
||||||
|
redirectLocalhost?: boolean; // Redirect localhost traffic to NetworkProxy
|
||||||
|
sslTerminationPort?: number; // Port where NetworkProxy handles SSL termination
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a rule added to iptables
|
||||||
|
*/
|
||||||
|
interface IpTablesRule {
|
||||||
|
table: string;
|
||||||
|
chain: string;
|
||||||
|
command: string;
|
||||||
|
tag: string;
|
||||||
|
added: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IPTablesProxy sets up iptables NAT rules to forward TCP traffic.
|
* IPTablesProxy sets up iptables NAT rules to forward TCP traffic.
|
||||||
* It only supports basic port forwarding and uses iptables comments to tag rules.
|
* Enhanced with multi-port support, IPv6, and integration with PortProxy/NetworkProxy.
|
||||||
*/
|
*/
|
||||||
export class IPTablesProxy {
|
export class IPTablesProxy {
|
||||||
public settings: IIpTableProxySettings;
|
public settings: IIpTableProxySettings;
|
||||||
private rulesInstalled: boolean = false;
|
private rules: IpTablesRule[] = [];
|
||||||
private ruleTag: string;
|
private ruleTag: string;
|
||||||
|
private customChain: string | null = null;
|
||||||
|
|
||||||
constructor(settings: IIpTableProxySettings) {
|
constructor(settings: IIpTableProxySettings) {
|
||||||
|
// Validate inputs to prevent command injection
|
||||||
|
this.validateSettings(settings);
|
||||||
|
|
||||||
|
// Set default settings
|
||||||
this.settings = {
|
this.settings = {
|
||||||
...settings,
|
...settings,
|
||||||
toHost: settings.toHost || 'localhost',
|
toHost: settings.toHost || 'localhost',
|
||||||
|
protocol: settings.protocol || 'tcp',
|
||||||
|
enableLogging: settings.enableLogging !== undefined ? settings.enableLogging : false,
|
||||||
|
ipv6Support: settings.ipv6Support !== undefined ? settings.ipv6Support : false,
|
||||||
|
checkExistingRules: settings.checkExistingRules !== undefined ? settings.checkExistingRules : true,
|
||||||
|
netProxyIntegration: settings.netProxyIntegration || { enabled: false }
|
||||||
};
|
};
|
||||||
// Generate a unique identifier for the rules added by this instance.
|
|
||||||
|
// Generate a unique identifier for the rules added by this instance
|
||||||
this.ruleTag = `IPTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`;
|
this.ruleTag = `IPTablesProxy:${Date.now()}:${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
|
||||||
// If deleteOnExit is true, register cleanup handlers.
|
if (this.settings.addJumpRule) {
|
||||||
|
this.customChain = `IPTablesProxy_${Math.random().toString(36).substr(2, 5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register cleanup handlers if deleteOnExit is true
|
||||||
if (this.settings.deleteOnExit) {
|
if (this.settings.deleteOnExit) {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
try {
|
try {
|
||||||
IPTablesProxy.cleanSlateSync();
|
this.stopSync();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error cleaning iptables rules on exit:', err);
|
console.error('Error cleaning iptables rules on exit:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on('exit', cleanup);
|
process.on('exit', cleanup);
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@ -53,76 +110,591 @@ export class IPTablesProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up iptables rules for port forwarding.
|
* Validates settings to prevent command injection and ensure valid values
|
||||||
* The rules are tagged with a unique comment so that they can be identified later.
|
|
||||||
*/
|
*/
|
||||||
public async start(): Promise<void> {
|
private validateSettings(settings: IIpTableProxySettings): void {
|
||||||
const dnatCmd = `iptables -t nat -A PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
|
// Validate port numbers
|
||||||
`-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
|
const validatePorts = (port: number | IPortRange | Array<number | IPortRange>) => {
|
||||||
`-m comment --comment "${this.ruleTag}:DNAT"`;
|
if (Array.isArray(port)) {
|
||||||
try {
|
port.forEach(p => validatePorts(p));
|
||||||
await execAsync(dnatCmd);
|
return;
|
||||||
console.log(`Added iptables rule: ${dnatCmd}`);
|
}
|
||||||
this.rulesInstalled = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to add iptables DNAT rule: ${err}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If preserveSourceIP is false, add a MASQUERADE rule.
|
if (typeof port === 'number') {
|
||||||
if (!this.settings.preserveSourceIP) {
|
if (port < 1 || port > 65535) {
|
||||||
const masqueradeCmd = `iptables -t nat -A POSTROUTING -p tcp -d ${this.settings.toHost} ` +
|
throw new Error(`Invalid port number: ${port}`);
|
||||||
`--dport ${this.settings.toPort} -j MASQUERADE ` +
|
|
||||||
`-m comment --comment "${this.ruleTag}:MASQ"`;
|
|
||||||
try {
|
|
||||||
await execAsync(masqueradeCmd);
|
|
||||||
console.log(`Added iptables rule: ${masqueradeCmd}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to add iptables MASQUERADE rule: ${err}`);
|
|
||||||
// Roll back the DNAT rule if MASQUERADE fails.
|
|
||||||
try {
|
|
||||||
const rollbackCmd = `iptables -t nat -D PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
|
|
||||||
`-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
|
|
||||||
`-m comment --comment "${this.ruleTag}:DNAT"`;
|
|
||||||
await execAsync(rollbackCmd);
|
|
||||||
this.rulesInstalled = false;
|
|
||||||
} catch (rollbackErr) {
|
|
||||||
console.error(`Rollback failed: ${rollbackErr}`);
|
|
||||||
}
|
}
|
||||||
throw err;
|
} else if (typeof port === 'object') {
|
||||||
|
if (port.from < 1 || port.from > 65535 || port.to < 1 || port.to > 65535 || port.from > port.to) {
|
||||||
|
throw new Error(`Invalid port range: ${port.from}-${port.to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validatePorts(settings.fromPort);
|
||||||
|
validatePorts(settings.toPort);
|
||||||
|
|
||||||
|
// Define regex patterns at the method level so they're available throughout
|
||||||
|
const ipRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
|
||||||
|
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,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,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,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$/;
|
||||||
|
|
||||||
|
// Validate IP addresses
|
||||||
|
const validateIPs = (ips?: string[]) => {
|
||||||
|
if (!ips) return;
|
||||||
|
|
||||||
|
for (const ip of ips) {
|
||||||
|
if (!ipRegex.test(ip) && !ipv6Regex.test(ip)) {
|
||||||
|
throw new Error(`Invalid IP address format: ${ip}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validateIPs(settings.allowedSourceIPs);
|
||||||
|
validateIPs(settings.bannedSourceIPs);
|
||||||
|
|
||||||
|
// Validate toHost - only allow hostnames or IPs
|
||||||
|
if (settings.toHost) {
|
||||||
|
const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
|
||||||
|
if (!hostRegex.test(settings.toHost) && !ipRegex.test(settings.toHost) && !ipv6Regex.test(settings.toHost)) {
|
||||||
|
throw new Error(`Invalid host format: ${settings.toHost}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the iptables rules that were added in start(), by matching the unique comment.
|
* Normalizes port specifications into an array of port ranges
|
||||||
*/
|
*/
|
||||||
public async stop(): Promise<void> {
|
private normalizePortSpec(portSpec: number | IPortRange | Array<number | IPortRange>): IPortRange[] {
|
||||||
if (!this.rulesInstalled) return;
|
const result: IPortRange[] = [];
|
||||||
|
|
||||||
const dnatDelCmd = `iptables -t nat -D PREROUTING -p tcp --dport ${this.settings.fromPort} ` +
|
if (Array.isArray(portSpec)) {
|
||||||
`-j DNAT --to-destination ${this.settings.toHost}:${this.settings.toPort} ` +
|
// If it's an array, process each element
|
||||||
`-m comment --comment "${this.ruleTag}:DNAT"`;
|
for (const spec of portSpec) {
|
||||||
try {
|
result.push(...this.normalizePortSpec(spec));
|
||||||
await execAsync(dnatDelCmd);
|
}
|
||||||
console.log(`Removed iptables rule: ${dnatDelCmd}`);
|
} else if (typeof portSpec === 'number') {
|
||||||
} catch (err) {
|
// Single port becomes a range with the same start and end
|
||||||
console.error(`Failed to remove iptables DNAT rule: ${err}`);
|
result.push({ from: portSpec, to: portSpec });
|
||||||
|
} else {
|
||||||
|
// Already a range
|
||||||
|
result.push(portSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.settings.preserveSourceIP) {
|
return result;
|
||||||
const masqueradeDelCmd = `iptables -t nat -D POSTROUTING -p tcp -d ${this.settings.toHost} ` +
|
}
|
||||||
`--dport ${this.settings.toPort} -j MASQUERADE ` +
|
|
||||||
`-m comment --comment "${this.ruleTag}:MASQ"`;
|
/**
|
||||||
try {
|
* Gets the appropriate iptables command based on settings
|
||||||
await execAsync(masqueradeDelCmd);
|
*/
|
||||||
console.log(`Removed iptables rule: ${masqueradeDelCmd}`);
|
private getIptablesCommand(isIpv6: boolean = false): string {
|
||||||
} catch (err) {
|
return isIpv6 ? 'ip6tables' : 'iptables';
|
||||||
console.error(`Failed to remove iptables MASQUERADE rule: ${err}`);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a rule already exists in iptables
|
||||||
|
*/
|
||||||
|
private async ruleExists(table: string, command: string, isIpv6: boolean = false): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const iptablesCmd = this.getIptablesCommand(isIpv6);
|
||||||
|
const { stdout } = await execAsync(`${iptablesCmd}-save -t ${table}`);
|
||||||
|
// Convert the command to the format found in iptables-save output
|
||||||
|
// (This is a simplification - in reality, you'd need more parsing)
|
||||||
|
const rulePattern = command.replace(`${iptablesCmd} -t ${table} -A `, '-A ');
|
||||||
|
return stdout.split('\n').some(line => line.trim() === rulePattern);
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to check if rule exists: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a custom chain for better rule management
|
||||||
|
*/
|
||||||
|
private async setupCustomChain(isIpv6: boolean = false): Promise<boolean> {
|
||||||
|
if (!this.customChain) return true;
|
||||||
|
|
||||||
|
const iptablesCmd = this.getIptablesCommand(isIpv6);
|
||||||
|
const table = 'nat';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the chain
|
||||||
|
await execAsync(`${iptablesCmd} -t ${table} -N ${this.customChain}`);
|
||||||
|
this.log('info', `Created custom chain: ${this.customChain}`);
|
||||||
|
|
||||||
|
// Add jump rule to PREROUTING chain
|
||||||
|
const jumpCommand = `${iptablesCmd} -t ${table} -A PREROUTING -j ${this.customChain} -m comment --comment "${this.ruleTag}:JUMP"`;
|
||||||
|
await execAsync(jumpCommand);
|
||||||
|
this.log('info', `Added jump rule to ${this.customChain}`);
|
||||||
|
|
||||||
|
// Store the jump rule
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain: 'PREROUTING',
|
||||||
|
command: jumpCommand,
|
||||||
|
tag: `${this.ruleTag}:JUMP`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to set up custom chain: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a source IP filter rule
|
||||||
|
*/
|
||||||
|
private async addSourceIPFilter(isIpv6: boolean = false): Promise<boolean> {
|
||||||
|
if (!this.settings.allowedSourceIPs && !this.settings.bannedSourceIPs) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iptablesCmd = this.getIptablesCommand(isIpv6);
|
||||||
|
const table = 'nat';
|
||||||
|
const chain = this.customChain || 'PREROUTING';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add banned IPs first (explicit deny)
|
||||||
|
if (this.settings.bannedSourceIPs && this.settings.bannedSourceIPs.length > 0) {
|
||||||
|
for (const ip of this.settings.bannedSourceIPs) {
|
||||||
|
const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -j DROP -m comment --comment "${this.ruleTag}:BANNED"`;
|
||||||
|
|
||||||
|
// Check if rule already exists
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${command}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(command);
|
||||||
|
this.log('info', `Added banned IP rule: ${command}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain,
|
||||||
|
command,
|
||||||
|
tag: `${this.ruleTag}:BANNED`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add allowed IPs (explicit allow)
|
||||||
|
if (this.settings.allowedSourceIPs && this.settings.allowedSourceIPs.length > 0) {
|
||||||
|
// First add a default deny for all
|
||||||
|
const denyAllCommand = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} -j DROP -m comment --comment "${this.ruleTag}:DENY_ALL"`;
|
||||||
|
|
||||||
|
// Add allow rules for specific IPs
|
||||||
|
for (const ip of this.settings.allowedSourceIPs) {
|
||||||
|
const command = `${iptablesCmd} -t ${table} -A ${chain} -s ${ip} -p ${this.settings.protocol} -j ACCEPT -m comment --comment "${this.ruleTag}:ALLOWED"`;
|
||||||
|
|
||||||
|
// Check if rule already exists
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${command}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(command);
|
||||||
|
this.log('info', `Added allowed IP rule: ${command}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain,
|
||||||
|
command,
|
||||||
|
tag: `${this.ruleTag}:ALLOWED`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now add the default deny after all allows
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists(table, denyAllCommand, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${denyAllCommand}`);
|
||||||
|
} else {
|
||||||
|
await execAsync(denyAllCommand);
|
||||||
|
this.log('info', `Added default deny rule: ${denyAllCommand}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain,
|
||||||
|
command: denyAllCommand,
|
||||||
|
tag: `${this.ruleTag}:DENY_ALL`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to add source IP filter rules: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a port forwarding rule
|
||||||
|
*/
|
||||||
|
private async addPortForwardingRule(
|
||||||
|
fromPortRange: IPortRange,
|
||||||
|
toPortRange: IPortRange,
|
||||||
|
isIpv6: boolean = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
const iptablesCmd = this.getIptablesCommand(isIpv6);
|
||||||
|
const table = 'nat';
|
||||||
|
const chain = this.customChain || 'PREROUTING';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle single port case
|
||||||
|
if (fromPortRange.from === fromPortRange.to && toPortRange.from === toPortRange.to) {
|
||||||
|
// Single port forward
|
||||||
|
const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from} ` +
|
||||||
|
`-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from} ` +
|
||||||
|
`-m comment --comment "${this.ruleTag}:DNAT"`;
|
||||||
|
|
||||||
|
// Check if rule already exists
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${command}`);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
this.log('info', `Added port forwarding rule: ${command}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain,
|
||||||
|
command,
|
||||||
|
tag: `${this.ruleTag}:DNAT`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (fromPortRange.to - fromPortRange.from === toPortRange.to - toPortRange.from) {
|
||||||
|
// Port range forward with equal ranges
|
||||||
|
const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPortRange.from}:${fromPortRange.to} ` +
|
||||||
|
`-j DNAT --to-destination ${this.settings.toHost}:${toPortRange.from}-${toPortRange.to} ` +
|
||||||
|
`-m comment --comment "${this.ruleTag}:DNAT_RANGE"`;
|
||||||
|
|
||||||
|
// Check if rule already exists
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${command}`);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
this.log('info', `Added port range forwarding rule: ${command}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain,
|
||||||
|
command,
|
||||||
|
tag: `${this.ruleTag}:DNAT_RANGE`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unequal port ranges need individual rules
|
||||||
|
for (let i = 0; i <= fromPortRange.to - fromPortRange.from; i++) {
|
||||||
|
const fromPort = fromPortRange.from + i;
|
||||||
|
const toPort = toPortRange.from + i % (toPortRange.to - toPortRange.from + 1);
|
||||||
|
|
||||||
|
const command = `${iptablesCmd} -t ${table} -A ${chain} -p ${this.settings.protocol} --dport ${fromPort} ` +
|
||||||
|
`-j DNAT --to-destination ${this.settings.toHost}:${toPort} ` +
|
||||||
|
`-m comment --comment "${this.ruleTag}:DNAT_INDIVIDUAL"`;
|
||||||
|
|
||||||
|
// Check if rule already exists
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists(table, command, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${command}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(command);
|
||||||
|
this.log('info', `Added individual port forwarding rule: ${command}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain,
|
||||||
|
command,
|
||||||
|
tag: `${this.ruleTag}:DNAT_INDIVIDUAL`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If preserveSourceIP is false, add a MASQUERADE rule
|
||||||
|
if (!this.settings.preserveSourceIP) {
|
||||||
|
// For port range
|
||||||
|
const masqCommand = `${iptablesCmd} -t nat -A POSTROUTING -p ${this.settings.protocol} -d ${this.settings.toHost} ` +
|
||||||
|
`--dport ${toPortRange.from}:${toPortRange.to} -j MASQUERADE ` +
|
||||||
|
`-m comment --comment "${this.ruleTag}:MASQ"`;
|
||||||
|
|
||||||
|
// Check if rule already exists
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists('nat', masqCommand, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${masqCommand}`);
|
||||||
|
} else {
|
||||||
|
await execAsync(masqCommand);
|
||||||
|
this.log('info', `Added MASQUERADE rule: ${masqCommand}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table: 'nat',
|
||||||
|
chain: 'POSTROUTING',
|
||||||
|
command: masqCommand,
|
||||||
|
tag: `${this.ruleTag}:MASQ`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to add port forwarding rule: ${err}`);
|
||||||
|
|
||||||
|
// Try to roll back any rules that were already added
|
||||||
|
await this.rollbackRules();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special handling for NetworkProxy integration
|
||||||
|
*/
|
||||||
|
private async setupNetworkProxyIntegration(isIpv6: boolean = false): Promise<boolean> {
|
||||||
|
if (!this.settings.netProxyIntegration?.enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const netProxyConfig = this.settings.netProxyIntegration;
|
||||||
|
const iptablesCmd = this.getIptablesCommand(isIpv6);
|
||||||
|
const table = 'nat';
|
||||||
|
const chain = this.customChain || 'PREROUTING';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If redirectLocalhost is true, set up special rule to redirect localhost traffic to NetworkProxy
|
||||||
|
if (netProxyConfig.redirectLocalhost && netProxyConfig.sslTerminationPort) {
|
||||||
|
const redirectCommand = `${iptablesCmd} -t ${table} -A OUTPUT -p tcp -d 127.0.0.1 -j REDIRECT ` +
|
||||||
|
`--to-port ${netProxyConfig.sslTerminationPort} ` +
|
||||||
|
`-m comment --comment "${this.ruleTag}:NETPROXY_REDIRECT"`;
|
||||||
|
|
||||||
|
// Check if rule already exists
|
||||||
|
if (this.settings.checkExistingRules && await this.ruleExists(table, redirectCommand, isIpv6)) {
|
||||||
|
this.log('info', `Rule already exists, skipping: ${redirectCommand}`);
|
||||||
|
} else {
|
||||||
|
await execAsync(redirectCommand);
|
||||||
|
this.log('info', `Added NetworkProxy redirection rule: ${redirectCommand}`);
|
||||||
|
|
||||||
|
this.rules.push({
|
||||||
|
table,
|
||||||
|
chain: 'OUTPUT',
|
||||||
|
command: redirectCommand,
|
||||||
|
tag: `${this.ruleTag}:NETPROXY_REDIRECT`,
|
||||||
|
added: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to set up NetworkProxy integration: ${err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls back rules that were added in case of error
|
||||||
|
*/
|
||||||
|
private async rollbackRules(): Promise<void> {
|
||||||
|
// Process rules in reverse order (LIFO)
|
||||||
|
for (let i = this.rules.length - 1; i >= 0; i--) {
|
||||||
|
const rule = this.rules[i];
|
||||||
|
|
||||||
|
if (rule.added) {
|
||||||
|
try {
|
||||||
|
// Convert -A (add) to -D (delete)
|
||||||
|
const deleteCommand = rule.command.replace('-A', '-D');
|
||||||
|
await execAsync(deleteCommand);
|
||||||
|
this.log('info', `Rolled back rule: ${deleteCommand}`);
|
||||||
|
|
||||||
|
rule.added = false;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to roll back rule: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up iptables rules for port forwarding with enhanced features
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
// Optionally clean the slate first
|
||||||
|
if (this.settings.forceCleanSlate) {
|
||||||
|
await IPTablesProxy.cleanSlate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// First set up any custom chains
|
||||||
|
if (this.settings.addJumpRule) {
|
||||||
|
const chainSetupSuccess = await this.setupCustomChain();
|
||||||
|
if (!chainSetupSuccess) {
|
||||||
|
throw new Error('Failed to set up custom chain');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For IPv6 if enabled
|
||||||
|
if (this.settings.ipv6Support) {
|
||||||
|
const chainSetupSuccessIpv6 = await this.setupCustomChain(true);
|
||||||
|
if (!chainSetupSuccessIpv6) {
|
||||||
|
this.log('warn', 'Failed to set up IPv6 custom chain, continuing with IPv4 only');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rulesInstalled = false;
|
// Add source IP filters
|
||||||
|
await this.addSourceIPFilter();
|
||||||
|
if (this.settings.ipv6Support) {
|
||||||
|
await this.addSourceIPFilter(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up NetworkProxy integration if enabled
|
||||||
|
if (this.settings.netProxyIntegration?.enabled) {
|
||||||
|
const netProxySetupSuccess = await this.setupNetworkProxyIntegration();
|
||||||
|
if (!netProxySetupSuccess) {
|
||||||
|
this.log('warn', 'Failed to set up NetworkProxy integration');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.settings.ipv6Support) {
|
||||||
|
await this.setupNetworkProxyIntegration(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize port specifications
|
||||||
|
const fromPortRanges = this.normalizePortSpec(this.settings.fromPort);
|
||||||
|
const toPortRanges = this.normalizePortSpec(this.settings.toPort);
|
||||||
|
|
||||||
|
// Handle the case where fromPort and toPort counts don't match
|
||||||
|
if (fromPortRanges.length !== toPortRanges.length) {
|
||||||
|
if (toPortRanges.length === 1) {
|
||||||
|
// If there's only one toPort, use it for all fromPorts
|
||||||
|
for (const fromRange of fromPortRanges) {
|
||||||
|
await this.addPortForwardingRule(fromRange, toPortRanges[0]);
|
||||||
|
|
||||||
|
if (this.settings.ipv6Support) {
|
||||||
|
await this.addPortForwardingRule(fromRange, toPortRanges[0], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Mismatched port counts: fromPort and toPort arrays must have equal length or toPort must be a single value');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add port forwarding rules for each port specification
|
||||||
|
for (let i = 0; i < fromPortRanges.length; i++) {
|
||||||
|
await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i]);
|
||||||
|
|
||||||
|
if (this.settings.ipv6Support) {
|
||||||
|
await this.addPortForwardingRule(fromPortRanges[i], toPortRanges[i], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final check - ensure we have at least one rule added
|
||||||
|
if (this.rules.filter(r => r.added).length === 0) {
|
||||||
|
throw new Error('No rules were added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all added iptables rules
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
// Process rules in reverse order (LIFO)
|
||||||
|
for (let i = this.rules.length - 1; i >= 0; i--) {
|
||||||
|
const rule = this.rules[i];
|
||||||
|
|
||||||
|
if (rule.added) {
|
||||||
|
try {
|
||||||
|
// Convert -A (add) to -D (delete)
|
||||||
|
const deleteCommand = rule.command.replace('-A', '-D');
|
||||||
|
await execAsync(deleteCommand);
|
||||||
|
this.log('info', `Removed rule: ${deleteCommand}`);
|
||||||
|
|
||||||
|
rule.added = false;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to remove rule: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we created a custom chain, we need to clean it up
|
||||||
|
if (this.customChain) {
|
||||||
|
try {
|
||||||
|
// First flush the chain
|
||||||
|
await execAsync(`iptables -t nat -F ${this.customChain}`);
|
||||||
|
this.log('info', `Flushed custom chain: ${this.customChain}`);
|
||||||
|
|
||||||
|
// Then delete it
|
||||||
|
await execAsync(`iptables -t nat -X ${this.customChain}`);
|
||||||
|
this.log('info', `Deleted custom chain: ${this.customChain}`);
|
||||||
|
|
||||||
|
// Same for IPv6 if enabled
|
||||||
|
if (this.settings.ipv6Support) {
|
||||||
|
try {
|
||||||
|
await execAsync(`ip6tables -t nat -F ${this.customChain}`);
|
||||||
|
await execAsync(`ip6tables -t nat -X ${this.customChain}`);
|
||||||
|
this.log('info', `Deleted IPv6 custom chain: ${this.customChain}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to delete IPv6 custom chain: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to delete custom chain: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear rules array
|
||||||
|
this.rules = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous version of stop, for use in exit handlers
|
||||||
|
*/
|
||||||
|
public stopSync(): void {
|
||||||
|
// Process rules in reverse order (LIFO)
|
||||||
|
for (let i = this.rules.length - 1; i >= 0; i--) {
|
||||||
|
const rule = this.rules[i];
|
||||||
|
|
||||||
|
if (rule.added) {
|
||||||
|
try {
|
||||||
|
// Convert -A (add) to -D (delete)
|
||||||
|
const deleteCommand = rule.command.replace('-A', '-D');
|
||||||
|
execSync(deleteCommand);
|
||||||
|
this.log('info', `Removed rule: ${deleteCommand}`);
|
||||||
|
|
||||||
|
rule.added = false;
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to remove rule: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we created a custom chain, we need to clean it up
|
||||||
|
if (this.customChain) {
|
||||||
|
try {
|
||||||
|
// First flush the chain
|
||||||
|
execSync(`iptables -t nat -F ${this.customChain}`);
|
||||||
|
|
||||||
|
// Then delete it
|
||||||
|
execSync(`iptables -t nat -X ${this.customChain}`);
|
||||||
|
this.log('info', `Deleted custom chain: ${this.customChain}`);
|
||||||
|
|
||||||
|
// Same for IPv6 if enabled
|
||||||
|
if (this.settings.ipv6Support) {
|
||||||
|
try {
|
||||||
|
execSync(`ip6tables -t nat -F ${this.customChain}`);
|
||||||
|
execSync(`ip6tables -t nat -X ${this.customChain}`);
|
||||||
|
} catch (err) {
|
||||||
|
// IPv6 failures are non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to delete custom chain: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear rules array
|
||||||
|
this.rules = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -130,26 +702,88 @@ export class IPTablesProxy {
|
|||||||
* It looks for rules with comments containing "IPTablesProxy:".
|
* It looks for rules with comments containing "IPTablesProxy:".
|
||||||
*/
|
*/
|
||||||
public static async cleanSlate(): Promise<void> {
|
public static async cleanSlate(): Promise<void> {
|
||||||
|
await IPTablesProxy.cleanSlateInternal();
|
||||||
|
|
||||||
|
// Also clean IPv6 rules
|
||||||
|
await IPTablesProxy.cleanSlateInternal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal implementation of cleanSlate with IPv6 support
|
||||||
|
*/
|
||||||
|
private static async cleanSlateInternal(isIpv6: boolean = false): Promise<void> {
|
||||||
|
const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync('iptables-save -t nat');
|
const { stdout } = await execAsync(`${iptablesCmd}-save -t nat`);
|
||||||
const lines = stdout.split('\n');
|
const lines = stdout.split('\n');
|
||||||
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
|
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
|
||||||
|
|
||||||
|
// First, find and remove any custom chains
|
||||||
|
const customChains = new Set<string>();
|
||||||
|
const jumpRules: string[] = [];
|
||||||
|
|
||||||
for (const line of proxyLines) {
|
for (const line of proxyLines) {
|
||||||
const trimmedLine = line.trim();
|
if (line.includes('IPTablesProxy:JUMP')) {
|
||||||
if (trimmedLine.startsWith('-A')) {
|
// Extract chain name from jump rule
|
||||||
// Replace the "-A" with "-D" to form a deletion command.
|
const match = line.match(/\s+-j\s+(\S+)\s+/);
|
||||||
const deleteRule = trimmedLine.replace('-A', '-D');
|
if (match && match[1].startsWith('IPTablesProxy_')) {
|
||||||
const cmd = `iptables -t nat ${deleteRule}`;
|
customChains.add(match[1]);
|
||||||
try {
|
jumpRules.push(line);
|
||||||
await execAsync(cmd);
|
|
||||||
console.log(`Cleaned up iptables rule: ${cmd}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to remove iptables rule: ${cmd}`, err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove jump rules first
|
||||||
|
for (const line of jumpRules) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('-A')) {
|
||||||
|
// Replace the "-A" with "-D" to form a deletion command
|
||||||
|
const deleteRule = trimmedLine.replace('-A', '-D');
|
||||||
|
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
|
||||||
|
try {
|
||||||
|
await execAsync(cmd);
|
||||||
|
console.log(`Cleaned up iptables jump rule: ${cmd}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to remove iptables jump rule: ${cmd}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then remove all other rules
|
||||||
|
for (const line of proxyLines) {
|
||||||
|
if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('-A')) {
|
||||||
|
// Replace the "-A" with "-D" to form a deletion command
|
||||||
|
const deleteRule = trimmedLine.replace('-A', '-D');
|
||||||
|
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
|
||||||
|
try {
|
||||||
|
await execAsync(cmd);
|
||||||
|
console.log(`Cleaned up iptables rule: ${cmd}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to remove iptables rule: ${cmd}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally clean up custom chains
|
||||||
|
for (const chain of customChains) {
|
||||||
|
try {
|
||||||
|
// Flush the chain
|
||||||
|
await execAsync(`${iptablesCmd} -t nat -F ${chain}`);
|
||||||
|
console.log(`Flushed custom chain: ${chain}`);
|
||||||
|
|
||||||
|
// Delete the chain
|
||||||
|
await execAsync(`${iptablesCmd} -t nat -X ${chain}`);
|
||||||
|
console.log(`Deleted custom chain: ${chain}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete custom chain ${chain}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to run iptables-save: ${err}`);
|
console.error(`Failed to run ${iptablesCmd}-save: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,25 +793,109 @@ export class IPTablesProxy {
|
|||||||
* This method is intended for use in process exit handlers.
|
* This method is intended for use in process exit handlers.
|
||||||
*/
|
*/
|
||||||
public static cleanSlateSync(): void {
|
public static cleanSlateSync(): void {
|
||||||
|
IPTablesProxy.cleanSlateSyncInternal();
|
||||||
|
|
||||||
|
// Also clean IPv6 rules
|
||||||
|
IPTablesProxy.cleanSlateSyncInternal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal implementation of cleanSlateSync with IPv6 support
|
||||||
|
*/
|
||||||
|
private static cleanSlateSyncInternal(isIpv6: boolean = false): void {
|
||||||
|
const iptablesCmd = isIpv6 ? 'ip6tables' : 'iptables';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stdout = execSync('iptables-save -t nat').toString();
|
const stdout = execSync(`${iptablesCmd}-save -t nat`).toString();
|
||||||
const lines = stdout.split('\n');
|
const lines = stdout.split('\n');
|
||||||
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
|
const proxyLines = lines.filter(line => line.includes('IPTablesProxy:'));
|
||||||
|
|
||||||
|
// First, find and remove any custom chains
|
||||||
|
const customChains = new Set<string>();
|
||||||
|
const jumpRules: string[] = [];
|
||||||
|
|
||||||
for (const line of proxyLines) {
|
for (const line of proxyLines) {
|
||||||
const trimmedLine = line.trim();
|
if (line.includes('IPTablesProxy:JUMP')) {
|
||||||
if (trimmedLine.startsWith('-A')) {
|
// Extract chain name from jump rule
|
||||||
const deleteRule = trimmedLine.replace('-A', '-D');
|
const match = line.match(/\s+-j\s+(\S+)\s+/);
|
||||||
const cmd = `iptables -t nat ${deleteRule}`;
|
if (match && match[1].startsWith('IPTablesProxy_')) {
|
||||||
try {
|
customChains.add(match[1]);
|
||||||
execSync(cmd);
|
jumpRules.push(line);
|
||||||
console.log(`Cleaned up iptables rule: ${cmd}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to remove iptables rule: ${cmd}`, err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove jump rules first
|
||||||
|
for (const line of jumpRules) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('-A')) {
|
||||||
|
// Replace the "-A" with "-D" to form a deletion command
|
||||||
|
const deleteRule = trimmedLine.replace('-A', '-D');
|
||||||
|
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
|
||||||
|
try {
|
||||||
|
execSync(cmd);
|
||||||
|
console.log(`Cleaned up iptables jump rule: ${cmd}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to remove iptables jump rule: ${cmd}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then remove all other rules
|
||||||
|
for (const line of proxyLines) {
|
||||||
|
if (!line.includes('IPTablesProxy:JUMP')) { // Skip jump rules we already handled
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('-A')) {
|
||||||
|
const deleteRule = trimmedLine.replace('-A', '-D');
|
||||||
|
const cmd = `${iptablesCmd} -t nat ${deleteRule}`;
|
||||||
|
try {
|
||||||
|
execSync(cmd);
|
||||||
|
console.log(`Cleaned up iptables rule: ${cmd}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to remove iptables rule: ${cmd}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally clean up custom chains
|
||||||
|
for (const chain of customChains) {
|
||||||
|
try {
|
||||||
|
// Flush the chain
|
||||||
|
execSync(`${iptablesCmd} -t nat -F ${chain}`);
|
||||||
|
|
||||||
|
// Delete the chain
|
||||||
|
execSync(`${iptablesCmd} -t nat -X ${chain}`);
|
||||||
|
console.log(`Deleted custom chain: ${chain}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to delete custom chain ${chain}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to run iptables-save: ${err}`);
|
console.error(`Failed to run ${iptablesCmd}-save: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logging utility that respects the enableLogging setting
|
||||||
|
*/
|
||||||
|
private log(level: 'info' | 'warn' | 'error', message: string): void {
|
||||||
|
if (!this.settings.enableLogging && level === 'info') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'info':
|
||||||
|
console.log(`[${timestamp}] [INFO] ${message}`);
|
||||||
|
break;
|
||||||
|
case 'warn':
|
||||||
|
console.warn(`[${timestamp}] [WARN] ${message}`);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
console.error(`[${timestamp}] [ERROR] ${message}`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -16,6 +16,10 @@ export interface INetworkProxyOptions {
|
|||||||
allowHeaders?: string;
|
allowHeaders?: string;
|
||||||
maxAge?: number;
|
maxAge?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// New settings for PortProxy integration
|
||||||
|
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
|
||||||
|
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
||||||
@ -42,14 +46,26 @@ export class NetworkProxy {
|
|||||||
public requestsServed: number = 0;
|
public requestsServed: number = 0;
|
||||||
public failedRequests: number = 0;
|
public failedRequests: number = 0;
|
||||||
|
|
||||||
|
// New tracking for PortProxy integration
|
||||||
|
private portProxyConnections: number = 0;
|
||||||
|
private tlsTerminatedConnections: number = 0;
|
||||||
|
|
||||||
// Timers and intervals
|
// Timers and intervals
|
||||||
private heartbeatInterval: NodeJS.Timeout;
|
private heartbeatInterval: NodeJS.Timeout;
|
||||||
private metricsInterval: NodeJS.Timeout;
|
private metricsInterval: NodeJS.Timeout;
|
||||||
|
private connectionPoolCleanupInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
// Certificates
|
// Certificates
|
||||||
private defaultCertificates: { key: string; cert: string };
|
private defaultCertificates: { key: string; cert: string };
|
||||||
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
|
private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map();
|
||||||
|
|
||||||
|
// New connection pool for backend connections
|
||||||
|
private connectionPool: Map<string, Array<{
|
||||||
|
socket: plugins.net.Socket;
|
||||||
|
lastUsed: number;
|
||||||
|
isIdle: boolean;
|
||||||
|
}>> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new NetworkProxy instance
|
* Creates a new NetworkProxy instance
|
||||||
*/
|
*/
|
||||||
@ -66,7 +82,10 @@ export class NetworkProxy {
|
|||||||
allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
|
allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
allowHeaders: 'Content-Type, Authorization',
|
allowHeaders: 'Content-Type, Authorization',
|
||||||
maxAge: 86400
|
maxAge: 86400
|
||||||
}
|
},
|
||||||
|
// New defaults for PortProxy integration
|
||||||
|
connectionPoolSize: optionsArg.connectionPoolSize || 50,
|
||||||
|
portProxyIntegration: optionsArg.portProxyIntegration || false
|
||||||
};
|
};
|
||||||
|
|
||||||
this.loadDefaultCertificates();
|
this.loadDefaultCertificates();
|
||||||
@ -104,6 +123,213 @@ export class NetworkProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the port number this NetworkProxy is listening on
|
||||||
|
* Useful for PortProxy to determine where to forward connections
|
||||||
|
*/
|
||||||
|
public getListeningPort(): number {
|
||||||
|
return this.options.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the server capacity settings
|
||||||
|
* @param maxConnections Maximum number of simultaneous connections
|
||||||
|
* @param keepAliveTimeout Keep-alive timeout in milliseconds
|
||||||
|
* @param connectionPoolSize Size of the connection pool per backend
|
||||||
|
*/
|
||||||
|
public updateCapacity(maxConnections?: number, keepAliveTimeout?: number, connectionPoolSize?: number): void {
|
||||||
|
if (maxConnections !== undefined) {
|
||||||
|
this.options.maxConnections = maxConnections;
|
||||||
|
this.log('info', `Updated max connections to ${maxConnections}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepAliveTimeout !== undefined) {
|
||||||
|
this.options.keepAliveTimeout = keepAliveTimeout;
|
||||||
|
|
||||||
|
if (this.httpsServer) {
|
||||||
|
this.httpsServer.keepAliveTimeout = keepAliveTimeout;
|
||||||
|
this.log('info', `Updated keep-alive timeout to ${keepAliveTimeout}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionPoolSize !== undefined) {
|
||||||
|
this.options.connectionPoolSize = connectionPoolSize;
|
||||||
|
this.log('info', `Updated connection pool size to ${connectionPoolSize}`);
|
||||||
|
|
||||||
|
// Cleanup excess connections in the pool if the size was reduced
|
||||||
|
this.cleanupConnectionPool();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current server metrics
|
||||||
|
* Useful for PortProxy to determine which NetworkProxy to use for load balancing
|
||||||
|
*/
|
||||||
|
public getMetrics(): any {
|
||||||
|
return {
|
||||||
|
activeConnections: this.connectedClients,
|
||||||
|
totalRequests: this.requestsServed,
|
||||||
|
failedRequests: this.failedRequests,
|
||||||
|
portProxyConnections: this.portProxyConnections,
|
||||||
|
tlsTerminatedConnections: this.tlsTerminatedConnections,
|
||||||
|
connectionPoolSize: Array.from(this.connectionPool.entries()).reduce((acc, [host, connections]) => {
|
||||||
|
acc[host] = connections.length;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||||
|
memoryUsage: process.memoryUsage(),
|
||||||
|
activeWebSockets: this.wsServer?.clients.size || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup the connection pool by removing idle connections
|
||||||
|
* or reducing pool size if it exceeds the configured maximum
|
||||||
|
*/
|
||||||
|
private cleanupConnectionPool(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const idleTimeout = this.options.keepAliveTimeout || 120000; // 2 minutes default
|
||||||
|
|
||||||
|
for (const [host, connections] of this.connectionPool.entries()) {
|
||||||
|
// Sort by last used time (oldest first)
|
||||||
|
connections.sort((a, b) => a.lastUsed - b.lastUsed);
|
||||||
|
|
||||||
|
// Remove idle connections older than the idle timeout
|
||||||
|
let removed = 0;
|
||||||
|
while (connections.length > 0) {
|
||||||
|
const connection = connections[0];
|
||||||
|
|
||||||
|
// Remove if idle and exceeds timeout, or if pool is too large
|
||||||
|
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
|
||||||
|
connections.length > this.options.connectionPoolSize!) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!connection.socket.destroyed) {
|
||||||
|
connection.socket.end();
|
||||||
|
connection.socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Error destroying pooled connection to ${host}`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
connections.shift(); // Remove from pool
|
||||||
|
removed++;
|
||||||
|
} else {
|
||||||
|
break; // Stop removing if we've reached active or recent connections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
this.log('debug', `Removed ${removed} idle connections from pool for ${host}, ${connections.length} remaining`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the pool with the remaining connections
|
||||||
|
if (connections.length === 0) {
|
||||||
|
this.connectionPool.delete(host);
|
||||||
|
} else {
|
||||||
|
this.connectionPool.set(host, connections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a connection from the pool or create a new one
|
||||||
|
*/
|
||||||
|
private getConnectionFromPool(host: string, port: number): Promise<plugins.net.Socket> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const poolKey = `${host}:${port}`;
|
||||||
|
const connectionList = this.connectionPool.get(poolKey) || [];
|
||||||
|
|
||||||
|
// Look for an idle connection
|
||||||
|
const idleConnectionIndex = connectionList.findIndex(c => c.isIdle);
|
||||||
|
|
||||||
|
if (idleConnectionIndex >= 0) {
|
||||||
|
// Get existing connection from pool
|
||||||
|
const connection = connectionList[idleConnectionIndex];
|
||||||
|
connection.isIdle = false;
|
||||||
|
connection.lastUsed = Date.now();
|
||||||
|
this.log('debug', `Reusing connection from pool for ${poolKey}`);
|
||||||
|
|
||||||
|
// Update the pool
|
||||||
|
this.connectionPool.set(poolKey, connectionList);
|
||||||
|
|
||||||
|
resolve(connection.socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No idle connection available, create a new one if pool isn't full
|
||||||
|
if (connectionList.length < this.options.connectionPoolSize!) {
|
||||||
|
this.log('debug', `Creating new connection to ${host}:${port}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = plugins.net.connect({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveInitialDelay: 30000 // 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once('connect', () => {
|
||||||
|
// Add to connection pool
|
||||||
|
const connection = {
|
||||||
|
socket,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
isIdle: false
|
||||||
|
};
|
||||||
|
|
||||||
|
connectionList.push(connection);
|
||||||
|
this.connectionPool.set(poolKey, connectionList);
|
||||||
|
|
||||||
|
// Setup cleanup when the connection is closed
|
||||||
|
socket.once('close', () => {
|
||||||
|
const idx = connectionList.findIndex(c => c.socket === socket);
|
||||||
|
if (idx >= 0) {
|
||||||
|
connectionList.splice(idx, 1);
|
||||||
|
this.connectionPool.set(poolKey, connectionList);
|
||||||
|
this.log('debug', `Removed closed connection from pool for ${poolKey}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once('error', (err) => {
|
||||||
|
this.log('error', `Error creating connection to ${host}:${port}`, err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.log('error', `Failed to create connection to ${host}:${port}`, err);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pool is full, wait for an idle connection or reject
|
||||||
|
this.log('warn', `Connection pool for ${poolKey} is full (${connectionList.length})`);
|
||||||
|
reject(new Error(`Connection pool for ${poolKey} is full`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a connection to the pool for reuse
|
||||||
|
*/
|
||||||
|
private returnConnectionToPool(socket: plugins.net.Socket, host: string, port: number): void {
|
||||||
|
const poolKey = `${host}:${port}`;
|
||||||
|
const connectionList = this.connectionPool.get(poolKey) || [];
|
||||||
|
|
||||||
|
// Find this connection in the pool
|
||||||
|
const connectionIndex = connectionList.findIndex(c => c.socket === socket);
|
||||||
|
|
||||||
|
if (connectionIndex >= 0) {
|
||||||
|
// Mark as idle and update last used time
|
||||||
|
connectionList[connectionIndex].isIdle = true;
|
||||||
|
connectionList[connectionIndex].lastUsed = Date.now();
|
||||||
|
|
||||||
|
this.log('debug', `Returned connection to pool for ${poolKey}`);
|
||||||
|
} else {
|
||||||
|
this.log('warn', `Attempted to return unknown connection to pool for ${poolKey}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the proxy server
|
* Starts the proxy server
|
||||||
*/
|
*/
|
||||||
@ -132,6 +358,9 @@ export class NetworkProxy {
|
|||||||
// Start metrics collection
|
// Start metrics collection
|
||||||
this.setupMetricsCollection();
|
this.setupMetricsCollection();
|
||||||
|
|
||||||
|
// Setup connection pool cleanup interval
|
||||||
|
this.setupConnectionPoolCleanup();
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.httpsServer.listen(this.options.port, () => {
|
this.httpsServer.listen(this.options.port, () => {
|
||||||
@ -156,13 +385,31 @@ export class NetworkProxy {
|
|||||||
// Add connection to tracking
|
// Add connection to tracking
|
||||||
this.socketMap.add(connection);
|
this.socketMap.add(connection);
|
||||||
this.connectedClients = this.socketMap.getArray().length;
|
this.connectedClients = this.socketMap.getArray().length;
|
||||||
this.log('debug', `New connection. Currently ${this.connectedClients} active connections`);
|
|
||||||
|
// Check for connection from PortProxy by inspecting the source port
|
||||||
|
// This is a heuristic - in a production environment you might use a more robust method
|
||||||
|
const localPort = connection.localPort;
|
||||||
|
const remotePort = connection.remotePort;
|
||||||
|
|
||||||
|
// If this connection is from a PortProxy (usually indicated by it coming from localhost)
|
||||||
|
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
|
||||||
|
this.portProxyConnections++;
|
||||||
|
this.log('debug', `New connection from PortProxy (local: ${localPort}, remote: ${remotePort})`);
|
||||||
|
} else {
|
||||||
|
this.log('debug', `New direct connection (local: ${localPort}, remote: ${remotePort})`);
|
||||||
|
}
|
||||||
|
|
||||||
// Setup connection cleanup handlers
|
// Setup connection cleanup handlers
|
||||||
const cleanupConnection = () => {
|
const cleanupConnection = () => {
|
||||||
if (this.socketMap.checkForObject(connection)) {
|
if (this.socketMap.checkForObject(connection)) {
|
||||||
this.socketMap.remove(connection);
|
this.socketMap.remove(connection);
|
||||||
this.connectedClients = this.socketMap.getArray().length;
|
this.connectedClients = this.socketMap.getArray().length;
|
||||||
|
|
||||||
|
// If this was a PortProxy connection, decrement the counter
|
||||||
|
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
|
||||||
|
this.portProxyConnections--;
|
||||||
|
}
|
||||||
|
|
||||||
this.log('debug', `Connection closed. ${this.connectedClients} connections remaining`);
|
this.log('debug', `Connection closed. ${this.connectedClients} connections remaining`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -178,6 +425,12 @@ export class NetworkProxy {
|
|||||||
cleanupConnection();
|
cleanupConnection();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track TLS handshake completions
|
||||||
|
this.httpsServer.on('secureConnection', (tlsSocket) => {
|
||||||
|
this.tlsTerminatedConnections++;
|
||||||
|
this.log('debug', 'TLS handshake completed, connection secured');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -228,15 +481,36 @@ export class NetworkProxy {
|
|||||||
activeConnections: this.connectedClients,
|
activeConnections: this.connectedClients,
|
||||||
totalRequests: this.requestsServed,
|
totalRequests: this.requestsServed,
|
||||||
failedRequests: this.failedRequests,
|
failedRequests: this.failedRequests,
|
||||||
|
portProxyConnections: this.portProxyConnections,
|
||||||
|
tlsTerminatedConnections: this.tlsTerminatedConnections,
|
||||||
activeWebSockets: this.wsServer?.clients.size || 0,
|
activeWebSockets: this.wsServer?.clients.size || 0,
|
||||||
memoryUsage: process.memoryUsage(),
|
memoryUsage: process.memoryUsage(),
|
||||||
activeContexts: Array.from(this.activeContexts)
|
activeContexts: Array.from(this.activeContexts),
|
||||||
|
connectionPool: Object.fromEntries(
|
||||||
|
Array.from(this.connectionPool.entries()).map(([host, connections]) => [
|
||||||
|
host,
|
||||||
|
{
|
||||||
|
total: connections.length,
|
||||||
|
idle: connections.filter(c => c.isIdle).length
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.log('debug', 'Proxy metrics', metrics);
|
this.log('debug', 'Proxy metrics', metrics);
|
||||||
}, 60000); // Log metrics every minute
|
}, 60000); // Log metrics every minute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up connection pool cleanup
|
||||||
|
*/
|
||||||
|
private setupConnectionPoolCleanup(): void {
|
||||||
|
// Clean up idle connections every minute
|
||||||
|
this.connectionPoolCleanupInterval = setInterval(() => {
|
||||||
|
this.cleanupConnectionPool();
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles an incoming WebSocket connection
|
* Handles an incoming WebSocket connection
|
||||||
*/
|
*/
|
||||||
@ -410,12 +684,27 @@ export class NetworkProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if we should use connection pooling
|
||||||
|
const useConnectionPool = this.options.portProxyIntegration &&
|
||||||
|
originRequest.socket.remoteAddress?.includes('127.0.0.1');
|
||||||
|
|
||||||
// Construct destination URL
|
// Construct destination URL
|
||||||
const destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
const destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
||||||
this.log('debug', `[${reqId}] Proxying to ${destinationUrl}`);
|
|
||||||
|
|
||||||
// Forward the request
|
if (useConnectionPool) {
|
||||||
await this.forwardRequest(reqId, originRequest, originResponse, destinationUrl);
|
this.log('debug', `[${reqId}] Proxying to ${destinationUrl} (using connection pool)`);
|
||||||
|
await this.forwardRequestUsingConnectionPool(
|
||||||
|
reqId,
|
||||||
|
originRequest,
|
||||||
|
originResponse,
|
||||||
|
destinationConfig.destinationIp,
|
||||||
|
destinationConfig.destinationPort,
|
||||||
|
originRequest.url
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.log('debug', `[${reqId}] Proxying to ${destinationUrl}`);
|
||||||
|
await this.forwardRequest(reqId, originRequest, originResponse, destinationUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const processingTime = Date.now() - startTime;
|
const processingTime = Date.now() - startTime;
|
||||||
this.log('debug', `[${reqId}] Request completed in ${processingTime}ms`);
|
this.log('debug', `[${reqId}] Request completed in ${processingTime}ms`);
|
||||||
@ -488,7 +777,105 @@ export class NetworkProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards a request to the destination
|
* Forwards a request to the destination using connection pool
|
||||||
|
* for optimized connection reuse from PortProxy
|
||||||
|
*/
|
||||||
|
private async forwardRequestUsingConnectionPool(
|
||||||
|
reqId: string,
|
||||||
|
originRequest: plugins.http.IncomingMessage,
|
||||||
|
originResponse: plugins.http.ServerResponse,
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
path: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Try to get a connection from the pool
|
||||||
|
const socket = await this.getConnectionFromPool(host, port);
|
||||||
|
|
||||||
|
// Create an HTTP client request using the pooled socket
|
||||||
|
const reqOptions = {
|
||||||
|
createConnection: () => socket,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
method: originRequest.method,
|
||||||
|
headers: this.prepareForwardHeaders(originRequest),
|
||||||
|
timeout: 30000 // 30 second timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyReq = plugins.http.request(reqOptions);
|
||||||
|
|
||||||
|
// Handle timeouts
|
||||||
|
proxyReq.on('timeout', () => {
|
||||||
|
this.log('warn', `[${reqId}] Request to ${host}:${port}${path} timed out`);
|
||||||
|
proxyReq.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
proxyReq.on('error', (err) => {
|
||||||
|
this.log('error', `[${reqId}] Error in proxy request to ${host}:${port}${path}`, err);
|
||||||
|
|
||||||
|
// Check if the client response is still writable
|
||||||
|
if (!originResponse.writableEnded) {
|
||||||
|
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Error communicating with upstream server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't return the socket to the pool on error
|
||||||
|
try {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (socketErr) {
|
||||||
|
this.log('error', `[${reqId}] Error destroying socket after request error`, socketErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward request body
|
||||||
|
originRequest.pipe(proxyReq);
|
||||||
|
|
||||||
|
// Handle response
|
||||||
|
proxyReq.on('response', (proxyRes) => {
|
||||||
|
// Copy status and headers
|
||||||
|
originResponse.statusCode = proxyRes.statusCode;
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(proxyRes.headers)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
originResponse.setHeader(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the response body
|
||||||
|
proxyRes.pipe(originResponse);
|
||||||
|
|
||||||
|
// Return connection to pool when the response completes
|
||||||
|
proxyRes.on('end', () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
this.returnConnectionToPool(socket, host, port);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyRes.on('error', (err) => {
|
||||||
|
this.log('error', `[${reqId}] Error in proxy response from ${host}:${port}${path}`, err);
|
||||||
|
|
||||||
|
// Don't return the socket to the pool on error
|
||||||
|
try {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (socketErr) {
|
||||||
|
this.log('error', `[${reqId}] Error destroying socket after response error`, socketErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log('error', `[${reqId}] Error setting up pooled connection to ${host}:${port}`, error);
|
||||||
|
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Unable to reach upstream server');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards a request to the destination (standard method)
|
||||||
*/
|
*/
|
||||||
private async forwardRequest(
|
private async forwardRequest(
|
||||||
reqId: string,
|
reqId: string,
|
||||||
@ -532,6 +919,11 @@ export class NetworkProxy {
|
|||||||
// Add proxy-specific headers
|
// Add proxy-specific headers
|
||||||
safeHeaders['X-Proxy-Id'] = `NetworkProxy-${this.options.port}`;
|
safeHeaders['X-Proxy-Id'] = `NetworkProxy-${this.options.port}`;
|
||||||
|
|
||||||
|
// If this is coming from PortProxy, add a header to indicate that
|
||||||
|
if (this.options.portProxyIntegration && req.socket.remoteAddress?.includes('127.0.0.1')) {
|
||||||
|
safeHeaders['X-PortProxy-Forwarded'] = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
// Remove sensitive headers we don't want to forward
|
// Remove sensitive headers we don't want to forward
|
||||||
const sensitiveHeaders = ['connection', 'upgrade', 'http2-settings'];
|
const sensitiveHeaders = ['connection', 'upgrade', 'http2-settings'];
|
||||||
for (const header of sensitiveHeaders) {
|
for (const header of sensitiveHeaders) {
|
||||||
@ -778,6 +1170,10 @@ export class NetworkProxy {
|
|||||||
clearInterval(this.metricsInterval);
|
clearInterval(this.metricsInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.connectionPoolCleanupInterval) {
|
||||||
|
clearInterval(this.connectionPoolCleanupInterval);
|
||||||
|
}
|
||||||
|
|
||||||
// Close WebSocket server if exists
|
// Close WebSocket server if exists
|
||||||
if (this.wsServer) {
|
if (this.wsServer) {
|
||||||
for (const client of this.wsServer.clients) {
|
for (const client of this.wsServer.clients) {
|
||||||
@ -798,6 +1194,20 @@ export class NetworkProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close all connection pool connections
|
||||||
|
for (const [host, connections] of this.connectionPool.entries()) {
|
||||||
|
for (const connection of connections) {
|
||||||
|
try {
|
||||||
|
if (!connection.socket.destroyed) {
|
||||||
|
connection.socket.destroy();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log('error', `Error destroying pooled connection to ${host}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.connectionPool.clear();
|
||||||
|
|
||||||
// Close the HTTPS server
|
// Close the HTTPS server
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.httpsServer.close(() => {
|
this.httpsServer.close(() => {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import * as http from 'http';
|
import * as plugins from './plugins.js';
|
||||||
import * as acme from 'acme-client';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a domain certificate with various status information
|
||||||
|
*/
|
||||||
interface IDomainCertificate {
|
interface IDomainCertificate {
|
||||||
certObtained: boolean;
|
certObtained: boolean;
|
||||||
obtainingInProgress: boolean;
|
obtainingInProgress: boolean;
|
||||||
@ -8,27 +10,147 @@ interface IDomainCertificate {
|
|||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
challengeToken?: string;
|
challengeToken?: string;
|
||||||
challengeKeyAuthorization?: string;
|
challengeKeyAuthorization?: string;
|
||||||
|
expiryDate?: Date;
|
||||||
|
lastRenewalAttempt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Port80Handler {
|
/**
|
||||||
private domainCertificates: Map<string, IDomainCertificate>;
|
* Configuration options for the ACME Certificate Manager
|
||||||
private server: http.Server;
|
*/
|
||||||
private acmeClient: acme.Client | null = null;
|
interface IAcmeCertManagerOptions {
|
||||||
private accountKey: string | null = null;
|
port?: number;
|
||||||
|
contactEmail?: string;
|
||||||
|
useProduction?: boolean;
|
||||||
|
renewThresholdDays?: number;
|
||||||
|
httpsRedirectPort?: number;
|
||||||
|
renewCheckIntervalHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
/**
|
||||||
|
* Certificate data that can be emitted via events or set from outside
|
||||||
|
*/
|
||||||
|
interface ICertificateData {
|
||||||
|
domain: string;
|
||||||
|
certificate: string;
|
||||||
|
privateKey: string;
|
||||||
|
expiryDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by the ACME Certificate Manager
|
||||||
|
*/
|
||||||
|
export enum CertManagerEvents {
|
||||||
|
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||||
|
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||||
|
CERTIFICATE_FAILED = 'certificate-failed',
|
||||||
|
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||||
|
MANAGER_STARTED = 'manager-started',
|
||||||
|
MANAGER_STOPPED = 'manager-stopped',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Improved ACME Certificate Manager with event emission and external certificate management
|
||||||
|
*/
|
||||||
|
export class AcmeCertManager extends plugins.EventEmitter {
|
||||||
|
private domainCertificates: Map<string, IDomainCertificate>;
|
||||||
|
private server: plugins.http.Server | null = null;
|
||||||
|
private acmeClient: plugins.acme.Client | null = null;
|
||||||
|
private accountKey: string | null = null;
|
||||||
|
private renewalTimer: NodeJS.Timeout | null = null;
|
||||||
|
private isShuttingDown: boolean = false;
|
||||||
|
private options: Required<IAcmeCertManagerOptions>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new ACME Certificate Manager
|
||||||
|
* @param options Configuration options
|
||||||
|
*/
|
||||||
|
constructor(options: IAcmeCertManagerOptions = {}) {
|
||||||
|
super();
|
||||||
this.domainCertificates = new Map<string, IDomainCertificate>();
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
||||||
|
|
||||||
// Create and start an HTTP server on port 80.
|
// Default options
|
||||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
this.options = {
|
||||||
this.server.listen(80, () => {
|
port: options.port ?? 80,
|
||||||
console.log('Port80Handler is listening on port 80');
|
contactEmail: options.contactEmail ?? 'admin@example.com',
|
||||||
|
useProduction: options.useProduction ?? false, // Safer default: staging
|
||||||
|
renewThresholdDays: options.renewThresholdDays ?? 30,
|
||||||
|
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
||||||
|
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the HTTP server for ACME challenges
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
throw new Error('Server is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
throw new Error('Server is shutting down');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
||||||
|
|
||||||
|
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'EACCES') {
|
||||||
|
reject(new Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`));
|
||||||
|
} else if (error.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`Port ${this.options.port} is already in use.`));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.listen(this.options.port, () => {
|
||||||
|
console.log(`AcmeCertManager is listening on port ${this.options.port}`);
|
||||||
|
this.startRenewalTimer();
|
||||||
|
this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a domain to be managed.
|
* Stops the HTTP server and renewal timer
|
||||||
* @param domain The domain to add.
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
// Stop the renewal timer
|
||||||
|
if (this.renewalTimer) {
|
||||||
|
clearInterval(this.renewalTimer);
|
||||||
|
this.renewalTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close(() => {
|
||||||
|
this.server = null;
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
this.emit(CertManagerEvents.MANAGER_STOPPED);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a domain to be managed for certificates
|
||||||
|
* @param domain The domain to add
|
||||||
*/
|
*/
|
||||||
public addDomain(domain: string): void {
|
public addDomain(domain: string): void {
|
||||||
if (!this.domainCertificates.has(domain)) {
|
if (!this.domainCertificates.has(domain)) {
|
||||||
@ -38,8 +160,8 @@ export class Port80Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a domain from management.
|
* Removes a domain from management
|
||||||
* @param domain The domain to remove.
|
* @param domain The domain to remove
|
||||||
*/
|
*/
|
||||||
public removeDomain(domain: string): void {
|
public removeDomain(domain: string): void {
|
||||||
if (this.domainCertificates.delete(domain)) {
|
if (this.domainCertificates.delete(domain)) {
|
||||||
@ -48,45 +170,116 @@ export class Port80Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy initialization of the ACME client.
|
* Sets a certificate for a domain directly (for externally obtained certificates)
|
||||||
* Uses Let’s Encrypt’s production directory (for testing you might switch to staging).
|
* @param domain The domain for the certificate
|
||||||
|
* @param certificate The certificate (PEM format)
|
||||||
|
* @param privateKey The private key (PEM format)
|
||||||
|
* @param expiryDate Optional expiry date
|
||||||
*/
|
*/
|
||||||
private async getAcmeClient(): Promise<acme.Client> {
|
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
||||||
|
let domainInfo = this.domainCertificates.get(domain);
|
||||||
|
|
||||||
|
if (!domainInfo) {
|
||||||
|
domainInfo = { certObtained: false, obtainingInProgress: false };
|
||||||
|
this.domainCertificates.set(domain, domainInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
domainInfo.certificate = certificate;
|
||||||
|
domainInfo.privateKey = privateKey;
|
||||||
|
domainInfo.certObtained = true;
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
|
||||||
|
if (expiryDate) {
|
||||||
|
domainInfo.expiryDate = expiryDate;
|
||||||
|
} else {
|
||||||
|
// Try to extract expiry date from certificate
|
||||||
|
try {
|
||||||
|
// This is a simplistic approach - in a real implementation, use a proper
|
||||||
|
// certificate parsing library like node-forge or x509
|
||||||
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||||
|
if (matches && matches[1]) {
|
||||||
|
domainInfo.expiryDate = new Date(matches[1]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Certificate set for ${domain}`);
|
||||||
|
|
||||||
|
// Emit certificate event
|
||||||
|
this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, {
|
||||||
|
domain,
|
||||||
|
certificate,
|
||||||
|
privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the certificate for a domain if it exists
|
||||||
|
* @param domain The domain to get the certificate for
|
||||||
|
*/
|
||||||
|
public getCertificate(domain: string): ICertificateData | null {
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
|
||||||
|
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
certificate: domainInfo.certificate,
|
||||||
|
privateKey: domainInfo.privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy initialization of the ACME client
|
||||||
|
* @returns An ACME client instance
|
||||||
|
*/
|
||||||
|
private async getAcmeClient(): Promise<plugins.acme.Client> {
|
||||||
if (this.acmeClient) {
|
if (this.acmeClient) {
|
||||||
return this.acmeClient;
|
return this.acmeClient;
|
||||||
}
|
}
|
||||||
// Generate a new account key and convert Buffer to string.
|
|
||||||
this.accountKey = (await acme.forge.createPrivateKey()).toString();
|
// Generate a new account key
|
||||||
this.acmeClient = new acme.Client({
|
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
|
||||||
directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate
|
|
||||||
// For testing, you could use:
|
this.acmeClient = new plugins.acme.Client({
|
||||||
// directoryUrl: acme.directory.letsencrypt.staging,
|
directoryUrl: this.options.useProduction
|
||||||
|
? plugins.acme.directory.letsencrypt.production
|
||||||
|
: plugins.acme.directory.letsencrypt.staging,
|
||||||
accountKey: this.accountKey,
|
accountKey: this.accountKey,
|
||||||
});
|
});
|
||||||
// Create a new account. Make sure to update the contact email.
|
|
||||||
|
// Create a new account
|
||||||
await this.acmeClient.createAccount({
|
await this.acmeClient.createAccount({
|
||||||
termsOfServiceAgreed: true,
|
termsOfServiceAgreed: true,
|
||||||
contact: ['mailto:admin@example.com'],
|
contact: [`mailto:${this.options.contactEmail}`],
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.acmeClient;
|
return this.acmeClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles incoming HTTP requests on port 80.
|
* Handles incoming HTTP requests
|
||||||
* If the request is for an ACME challenge, it responds with the key authorization.
|
* @param req The HTTP request
|
||||||
* If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance.
|
* @param res The HTTP response
|
||||||
*/
|
*/
|
||||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||||
const hostHeader = req.headers.host;
|
const hostHeader = req.headers.host;
|
||||||
if (!hostHeader) {
|
if (!hostHeader) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Bad Request: Host header is missing');
|
res.end('Bad Request: Host header is missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (ignoring any port in the Host header)
|
// Extract domain (ignoring any port in the Host header)
|
||||||
const domain = hostHeader.split(':')[0];
|
const domain = hostHeader.split(':')[0];
|
||||||
|
|
||||||
// If the request is for an ACME HTTP-01 challenge, handle it.
|
// If the request is for an ACME HTTP-01 challenge, handle it
|
||||||
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
||||||
this.handleAcmeChallenge(req, res, domain);
|
this.handleAcmeChallenge(req, res, domain);
|
||||||
return;
|
return;
|
||||||
@ -100,38 +293,47 @@ export class Port80Handler {
|
|||||||
|
|
||||||
const domainInfo = this.domainCertificates.get(domain)!;
|
const domainInfo = this.domainCertificates.get(domain)!;
|
||||||
|
|
||||||
// If certificate exists, redirect to HTTPS on port 443.
|
// If certificate exists, redirect to HTTPS
|
||||||
if (domainInfo.certObtained) {
|
if (domainInfo.certObtained) {
|
||||||
const redirectUrl = `https://${domain}:443${req.url}`;
|
const httpsPort = this.options.httpsRedirectPort;
|
||||||
|
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
||||||
|
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
||||||
|
|
||||||
res.statusCode = 301;
|
res.statusCode = 301;
|
||||||
res.setHeader('Location', redirectUrl);
|
res.setHeader('Location', redirectUrl);
|
||||||
res.end(`Redirecting to ${redirectUrl}`);
|
res.end(`Redirecting to ${redirectUrl}`);
|
||||||
} else {
|
} else {
|
||||||
// Trigger certificate issuance if not already running.
|
// Trigger certificate issuance if not already running
|
||||||
if (!domainInfo.obtainingInProgress) {
|
if (!domainInfo.obtainingInProgress) {
|
||||||
domainInfo.obtainingInProgress = true;
|
|
||||||
this.obtainCertificate(domain).catch(err => {
|
this.obtainCertificate(domain).catch(err => {
|
||||||
|
this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message });
|
||||||
console.error(`Error obtaining certificate for ${domain}:`, err);
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.statusCode = 503;
|
res.statusCode = 503;
|
||||||
res.end('Certificate issuance in progress, please try again later.');
|
res.end('Certificate issuance in progress, please try again later.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves the ACME HTTP-01 challenge response.
|
* Serves the ACME HTTP-01 challenge response
|
||||||
|
* @param req The HTTP request
|
||||||
|
* @param res The HTTP response
|
||||||
|
* @param domain The domain for the challenge
|
||||||
*/
|
*/
|
||||||
private handleAcmeChallenge(req: http.IncomingMessage, res: http.ServerResponse, domain: string): void {
|
private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void {
|
||||||
const domainInfo = this.domainCertificates.get(domain);
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
if (!domainInfo) {
|
if (!domainInfo) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end('Domain not configured');
|
res.end('Domain not configured');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The token is the last part of the URL.
|
|
||||||
|
// The token is the last part of the URL
|
||||||
const urlParts = req.url?.split('/');
|
const urlParts = req.url?.split('/');
|
||||||
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
||||||
|
|
||||||
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
@ -144,71 +346,214 @@ export class Port80Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uses acme-client to perform a full ACME HTTP-01 challenge to obtain a certificate.
|
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
||||||
* On success, it stores the certificate and key in memory and clears challenge data.
|
* @param domain The domain to obtain a certificate for
|
||||||
|
* @param isRenewal Whether this is a renewal attempt
|
||||||
*/
|
*/
|
||||||
private async obtainCertificate(domain: string): Promise<void> {
|
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
||||||
|
// Get the domain info
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
if (!domainInfo) {
|
||||||
|
throw new Error(`Domain not found: ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent concurrent certificate issuance
|
||||||
|
if (domainInfo.obtainingInProgress) {
|
||||||
|
console.log(`Certificate issuance already in progress for ${domain}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
domainInfo.obtainingInProgress = true;
|
||||||
|
domainInfo.lastRenewalAttempt = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await this.getAcmeClient();
|
const client = await this.getAcmeClient();
|
||||||
|
|
||||||
// Create a new order for the domain.
|
// Create a new order for the domain
|
||||||
const order = await client.createOrder({
|
const order = await client.createOrder({
|
||||||
identifiers: [{ type: 'dns', value: domain }],
|
identifiers: [{ type: 'dns', value: domain }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the authorizations for the order.
|
// Get the authorizations for the order
|
||||||
const authorizations = await client.getAuthorizations(order);
|
const authorizations = await client.getAuthorizations(order);
|
||||||
|
|
||||||
for (const authz of authorizations) {
|
for (const authz of authorizations) {
|
||||||
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
throw new Error('HTTP-01 challenge not found');
|
throw new Error('HTTP-01 challenge not found');
|
||||||
}
|
}
|
||||||
// Get the key authorization for the challenge.
|
|
||||||
|
// Get the key authorization for the challenge
|
||||||
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
||||||
const domainInfo = this.domainCertificates.get(domain)!;
|
|
||||||
|
// Store the challenge data
|
||||||
domainInfo.challengeToken = challenge.token;
|
domainInfo.challengeToken = challenge.token;
|
||||||
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
||||||
|
|
||||||
// Notify the ACME server that the challenge is ready.
|
// ACME client type definition workaround - use compatible approach
|
||||||
// The acme-client examples show that verifyChallenge takes three arguments:
|
// First check if challenge verification is needed
|
||||||
// (authorization, challenge, keyAuthorization). However, the official TypeScript
|
const authzUrl = authz.url;
|
||||||
// types appear to be out-of-sync. As a workaround, we cast client to 'any'.
|
|
||||||
await (client as any).verifyChallenge(authz, challenge, keyAuthorization);
|
|
||||||
|
|
||||||
await client.completeChallenge(challenge);
|
try {
|
||||||
// Wait until the challenge is validated.
|
// Check if authzUrl exists and perform verification
|
||||||
await client.waitForValidStatus(challenge);
|
if (authzUrl) {
|
||||||
console.log(`HTTP-01 challenge completed for ${domain}`);
|
await client.verifyChallenge(authz, challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete the challenge
|
||||||
|
await client.completeChallenge(challenge);
|
||||||
|
|
||||||
|
// Wait for validation
|
||||||
|
await client.waitForValidStatus(challenge);
|
||||||
|
console.log(`HTTP-01 challenge completed for ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Challenge error for ${domain}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a CSR and a new private key for the domain.
|
// Generate a CSR and private key
|
||||||
// Convert the resulting Buffers to strings.
|
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
|
||||||
const [csrBuffer, privateKeyBuffer] = await acme.forge.createCsr({
|
|
||||||
commonName: domain,
|
commonName: domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const csr = csrBuffer.toString();
|
const csr = csrBuffer.toString();
|
||||||
const privateKey = privateKeyBuffer.toString();
|
const privateKey = privateKeyBuffer.toString();
|
||||||
|
|
||||||
// Finalize the order and obtain the certificate.
|
// Finalize the order with our CSR
|
||||||
await client.finalizeOrder(order, csr);
|
await client.finalizeOrder(order, csr);
|
||||||
|
|
||||||
|
// Get the certificate with the full chain
|
||||||
const certificate = await client.getCertificate(order);
|
const certificate = await client.getCertificate(order);
|
||||||
|
|
||||||
const domainInfo = this.domainCertificates.get(domain)!;
|
// Store the certificate and key
|
||||||
domainInfo.certificate = certificate;
|
domainInfo.certificate = certificate;
|
||||||
domainInfo.privateKey = privateKey;
|
domainInfo.privateKey = privateKey;
|
||||||
domainInfo.certObtained = true;
|
domainInfo.certObtained = true;
|
||||||
domainInfo.obtainingInProgress = false;
|
|
||||||
|
// Clear challenge data
|
||||||
delete domainInfo.challengeToken;
|
delete domainInfo.challengeToken;
|
||||||
delete domainInfo.challengeKeyAuthorization;
|
delete domainInfo.challengeKeyAuthorization;
|
||||||
|
|
||||||
console.log(`Certificate obtained for ${domain}`);
|
// Extract expiry date from certificate
|
||||||
// In a production system, persist the certificate and key and reload your TLS server.
|
try {
|
||||||
} catch (error) {
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||||
console.error(`Error during certificate issuance for ${domain}:`, error);
|
if (matches && matches[1]) {
|
||||||
const domainInfo = this.domainCertificates.get(domain);
|
domainInfo.expiryDate = new Date(matches[1]);
|
||||||
if (domainInfo) {
|
console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`);
|
||||||
domainInfo.obtainingInProgress = false;
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
||||||
|
|
||||||
|
// Emit the appropriate event
|
||||||
|
const eventType = isRenewal
|
||||||
|
? CertManagerEvents.CERTIFICATE_RENEWED
|
||||||
|
: CertManagerEvents.CERTIFICATE_ISSUED;
|
||||||
|
|
||||||
|
this.emitCertificateEvent(eventType, {
|
||||||
|
domain,
|
||||||
|
certificate,
|
||||||
|
privateKey,
|
||||||
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check for rate limit errors
|
||||||
|
if (error.message && (
|
||||||
|
error.message.includes('rateLimited') ||
|
||||||
|
error.message.includes('too many certificates') ||
|
||||||
|
error.message.includes('rate limit')
|
||||||
|
)) {
|
||||||
|
console.error(`Rate limit reached for ${domain}. Waiting before retry.`);
|
||||||
|
} else {
|
||||||
|
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit failure event
|
||||||
|
this.emit(CertManagerEvents.CERTIFICATE_FAILED, {
|
||||||
|
domain,
|
||||||
|
error: error.message || 'Unknown error',
|
||||||
|
isRenewal
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Reset flag whether successful or not
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the certificate renewal timer
|
||||||
|
*/
|
||||||
|
private startRenewalTimer(): void {
|
||||||
|
if (this.renewalTimer) {
|
||||||
|
clearInterval(this.renewalTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hours to milliseconds
|
||||||
|
const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval);
|
||||||
|
|
||||||
|
// Prevent the timer from keeping the process alive
|
||||||
|
if (this.renewalTimer.unref) {
|
||||||
|
this.renewalTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for certificates that need renewal
|
||||||
|
*/
|
||||||
|
private checkForRenewals(): void {
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Checking for certificates that need renewal...');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||||
|
// Skip domains without certificates or already in renewal
|
||||||
|
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip domains without expiry dates
|
||||||
|
if (!domainInfo.expiryDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime();
|
||||||
|
|
||||||
|
// Check if certificate is near expiry
|
||||||
|
if (timeUntilExpiry <= renewThresholdMs) {
|
||||||
|
console.log(`Certificate for ${domain} expires soon, renewing...`);
|
||||||
|
this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, {
|
||||||
|
domain,
|
||||||
|
expiryDate: domainInfo.expiryDate,
|
||||||
|
daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start renewal process
|
||||||
|
this.obtainCertificate(domain, true).catch(err => {
|
||||||
|
console.error(`Error renewing certificate for ${domain}:`, err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a certificate event with the certificate data
|
||||||
|
* @param eventType The event type to emit
|
||||||
|
* @param data The certificate data
|
||||||
|
*/
|
||||||
|
private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void {
|
||||||
|
this.emit(eventType, data);
|
||||||
|
}
|
||||||
}
|
}
|
File diff suppressed because it is too large
Load Diff
@ -1,33 +1,351 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as http from 'http';
|
||||||
|
import * as url from 'url';
|
||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional path pattern configuration that can be added to proxy configs
|
||||||
|
*/
|
||||||
|
export interface IPathPatternConfig {
|
||||||
|
pathPattern?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for router result with additional metadata
|
||||||
|
*/
|
||||||
|
export interface IRouterResult {
|
||||||
|
config: tsclass.network.IReverseProxyConfig;
|
||||||
|
pathMatch?: string;
|
||||||
|
pathParams?: Record<string, string>;
|
||||||
|
pathRemainder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ProxyRouter {
|
export class ProxyRouter {
|
||||||
public reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
// Store original configs for reference
|
||||||
|
private reverseProxyConfigs: tsclass.network.IReverseProxyConfig[] = [];
|
||||||
|
// Default config to use when no match is found (optional)
|
||||||
|
private defaultConfig?: tsclass.network.IReverseProxyConfig;
|
||||||
|
// Store path patterns separately since they're not in the original interface
|
||||||
|
private pathPatterns: Map<tsclass.network.IReverseProxyConfig, string> = new Map();
|
||||||
|
// Logger interface
|
||||||
|
private logger: {
|
||||||
|
error: (message: string, data?: any) => void;
|
||||||
|
warn: (message: string, data?: any) => void;
|
||||||
|
info: (message: string, data?: any) => void;
|
||||||
|
debug: (message: string, data?: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
constructor(
|
||||||
* sets a new set of reverse configs to be routed to
|
configs?: tsclass.network.IReverseProxyConfig[],
|
||||||
* @param reverseCandidatesArg
|
logger?: {
|
||||||
*/
|
error: (message: string, data?: any) => void;
|
||||||
public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]) {
|
warn: (message: string, data?: any) => void;
|
||||||
this.reverseProxyConfigs = reverseCandidatesArg;
|
info: (message: string, data?: any) => void;
|
||||||
|
debug: (message: string, data?: any) => void;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.logger = logger || console;
|
||||||
|
if (configs) {
|
||||||
|
this.setNewProxyConfigs(configs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* routes a request
|
* Sets a new set of reverse configs to be routed to
|
||||||
|
* @param reverseCandidatesArg Array of reverse proxy configurations
|
||||||
*/
|
*/
|
||||||
public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig {
|
public setNewProxyConfigs(reverseCandidatesArg: tsclass.network.IReverseProxyConfig[]): void {
|
||||||
|
this.reverseProxyConfigs = [...reverseCandidatesArg];
|
||||||
|
|
||||||
|
// Find default config if any (config with "*" as hostname)
|
||||||
|
this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*');
|
||||||
|
|
||||||
|
this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.getHostnames().length} unique hosts)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a request based on hostname and path
|
||||||
|
* @param req The incoming HTTP request
|
||||||
|
* @returns The matching proxy config or undefined if no match found
|
||||||
|
*/
|
||||||
|
public routeReq(req: http.IncomingMessage): tsclass.network.IReverseProxyConfig {
|
||||||
|
const result = this.routeReqWithDetails(req);
|
||||||
|
return result ? result.config : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a request with detailed matching information
|
||||||
|
* @param req The incoming HTTP request
|
||||||
|
* @returns Detailed routing result including matched config and path information
|
||||||
|
*/
|
||||||
|
public routeReqWithDetails(req: http.IncomingMessage): IRouterResult | undefined {
|
||||||
|
// Extract and validate host header
|
||||||
const originalHost = req.headers.host;
|
const originalHost = req.headers.host;
|
||||||
if (!originalHost) {
|
if (!originalHost) {
|
||||||
console.error('No host header found in request');
|
this.logger.error('No host header found in request');
|
||||||
|
return this.defaultConfig ? { config: this.defaultConfig } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL for path matching
|
||||||
|
const parsedUrl = url.parse(req.url || '/');
|
||||||
|
const urlPath = parsedUrl.pathname || '/';
|
||||||
|
|
||||||
|
// Extract hostname without port
|
||||||
|
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
|
||||||
|
|
||||||
|
// First try exact hostname match
|
||||||
|
const exactConfig = this.findConfigForHost(hostWithoutPort, urlPath);
|
||||||
|
if (exactConfig) {
|
||||||
|
return exactConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try wildcard subdomain
|
||||||
|
if (hostWithoutPort.includes('.')) {
|
||||||
|
const domainParts = hostWithoutPort.split('.');
|
||||||
|
if (domainParts.length > 2) {
|
||||||
|
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
|
||||||
|
const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath);
|
||||||
|
if (wildcardConfig) {
|
||||||
|
return wildcardConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default config if available
|
||||||
|
if (this.defaultConfig) {
|
||||||
|
this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`);
|
||||||
|
return { config: this.defaultConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(`No config found for host: ${hostWithoutPort}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a config for a specific host and path
|
||||||
|
*/
|
||||||
|
private findConfigForHost(hostname: string, path: string): IRouterResult | undefined {
|
||||||
|
// Find all configs for this hostname
|
||||||
|
const configs = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName.toLowerCase() === hostname.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (configs.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
// Strip port from host if present
|
|
||||||
const hostWithoutPort = originalHost.split(':')[0];
|
// First try configs with path patterns
|
||||||
const correspodingReverseProxyConfig = this.reverseProxyConfigs.find((reverseConfig) => {
|
const configsWithPaths = configs.filter(config => this.pathPatterns.has(config));
|
||||||
return reverseConfig.hostName === hostWithoutPort;
|
|
||||||
|
// Sort by path pattern specificity - more specific first
|
||||||
|
configsWithPaths.sort((a, b) => {
|
||||||
|
const aPattern = this.pathPatterns.get(a) || '';
|
||||||
|
const bPattern = this.pathPatterns.get(b) || '';
|
||||||
|
|
||||||
|
// Exact patterns come before wildcard patterns
|
||||||
|
const aHasWildcard = aPattern.includes('*');
|
||||||
|
const bHasWildcard = bPattern.includes('*');
|
||||||
|
|
||||||
|
if (aHasWildcard && !bHasWildcard) return 1;
|
||||||
|
if (!aHasWildcard && bHasWildcard) return -1;
|
||||||
|
|
||||||
|
// Longer patterns are considered more specific
|
||||||
|
return bPattern.length - aPattern.length;
|
||||||
});
|
});
|
||||||
if (!correspodingReverseProxyConfig) {
|
|
||||||
console.error(`No config found for host: ${hostWithoutPort}`);
|
// Check each config with path pattern
|
||||||
|
for (const config of configsWithPaths) {
|
||||||
|
const pathPattern = this.pathPatterns.get(config);
|
||||||
|
if (pathPattern) {
|
||||||
|
const pathMatch = this.matchPath(path, pathPattern);
|
||||||
|
if (pathMatch) {
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
pathMatch: pathMatch.matched,
|
||||||
|
pathParams: pathMatch.params,
|
||||||
|
pathRemainder: pathMatch.remainder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return correspodingReverseProxyConfig;
|
|
||||||
|
// If no path pattern matched, use the first config without a path pattern
|
||||||
|
const configWithoutPath = configs.find(config => !this.pathPatterns.has(config));
|
||||||
|
if (configWithoutPath) {
|
||||||
|
return { config: configWithoutPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a URL path against a pattern
|
||||||
|
* Supports:
|
||||||
|
* - Exact matches: /users/profile
|
||||||
|
* - Wildcards: /api/* (matches any path starting with /api/)
|
||||||
|
* - Path parameters: /users/:id (captures id as a parameter)
|
||||||
|
*
|
||||||
|
* @param path The URL path to match
|
||||||
|
* @param pattern The pattern to match against
|
||||||
|
* @returns Match result with params and remainder, or null if no match
|
||||||
|
*/
|
||||||
|
private matchPath(path: string, pattern: string): {
|
||||||
|
matched: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
remainder: string;
|
||||||
|
} | null {
|
||||||
|
// Handle exact match
|
||||||
|
if (path === pattern) {
|
||||||
|
return {
|
||||||
|
matched: pattern,
|
||||||
|
params: {},
|
||||||
|
remainder: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard match
|
||||||
|
if (pattern.endsWith('/*')) {
|
||||||
|
const prefix = pattern.slice(0, -2);
|
||||||
|
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
||||||
|
return {
|
||||||
|
matched: prefix,
|
||||||
|
params: {},
|
||||||
|
remainder: path.slice(prefix.length)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle path parameters
|
||||||
|
const patternParts = pattern.split('/').filter(p => p);
|
||||||
|
const pathParts = path.split('/').filter(p => p);
|
||||||
|
|
||||||
|
// Too few path parts to match
|
||||||
|
if (pathParts.length < patternParts.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Compare each part
|
||||||
|
for (let i = 0; i < patternParts.length; i++) {
|
||||||
|
const patternPart = patternParts[i];
|
||||||
|
const pathPart = pathParts[i];
|
||||||
|
|
||||||
|
// Handle parameter
|
||||||
|
if (patternPart.startsWith(':')) {
|
||||||
|
const paramName = patternPart.slice(1);
|
||||||
|
params[paramName] = pathPart;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard at the end
|
||||||
|
if (patternPart === '*' && i === patternParts.length - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exact match for this part
|
||||||
|
if (patternPart !== pathPart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the remainder - the unmatched path parts
|
||||||
|
const remainderParts = pathParts.slice(patternParts.length);
|
||||||
|
const remainder = remainderParts.length ? '/' + remainderParts.join('/') : '';
|
||||||
|
|
||||||
|
// Calculate the matched path
|
||||||
|
const matchedParts = patternParts.map((part, i) => {
|
||||||
|
return part.startsWith(':') ? pathParts[i] : part;
|
||||||
|
});
|
||||||
|
const matched = '/' + matchedParts.join('/');
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
params,
|
||||||
|
remainder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all currently active proxy configurations
|
||||||
|
* @returns Array of all active configurations
|
||||||
|
*/
|
||||||
|
public getProxyConfigs(): tsclass.network.IReverseProxyConfig[] {
|
||||||
|
return [...this.reverseProxyConfigs];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all hostnames that this router is configured to handle
|
||||||
|
* @returns Array of hostnames
|
||||||
|
*/
|
||||||
|
public getHostnames(): string[] {
|
||||||
|
const hostnames = new Set<string>();
|
||||||
|
for (const config of this.reverseProxyConfigs) {
|
||||||
|
if (config.hostName !== '*') {
|
||||||
|
hostnames.add(config.hostName.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(hostnames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a single new proxy configuration
|
||||||
|
* @param config The configuration to add
|
||||||
|
* @param pathPattern Optional path pattern for route matching
|
||||||
|
*/
|
||||||
|
public addProxyConfig(
|
||||||
|
config: tsclass.network.IReverseProxyConfig,
|
||||||
|
pathPattern?: string
|
||||||
|
): void {
|
||||||
|
this.reverseProxyConfigs.push(config);
|
||||||
|
|
||||||
|
// Store path pattern if provided
|
||||||
|
if (pathPattern) {
|
||||||
|
this.pathPatterns.set(config, pathPattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a path pattern for an existing config
|
||||||
|
* @param config The existing configuration
|
||||||
|
* @param pathPattern The path pattern to set
|
||||||
|
* @returns Boolean indicating if the config was found and updated
|
||||||
|
*/
|
||||||
|
public setPathPattern(
|
||||||
|
config: tsclass.network.IReverseProxyConfig,
|
||||||
|
pathPattern: string
|
||||||
|
): boolean {
|
||||||
|
const exists = this.reverseProxyConfigs.includes(config);
|
||||||
|
if (exists) {
|
||||||
|
this.pathPatterns.set(config, pathPattern);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a proxy configuration by hostname
|
||||||
|
* @param hostname The hostname to remove
|
||||||
|
* @returns Boolean indicating whether any configs were removed
|
||||||
|
*/
|
||||||
|
public removeProxyConfig(hostname: string): boolean {
|
||||||
|
const initialCount = this.reverseProxyConfigs.length;
|
||||||
|
|
||||||
|
// Find configs to remove
|
||||||
|
const configsToRemove = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName === hostname
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove them from the patterns map
|
||||||
|
for (const config of configsToRemove) {
|
||||||
|
this.pathPatterns.delete(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter them out of the configs array
|
||||||
|
this.reverseProxyConfigs = this.reverseProxyConfigs.filter(
|
||||||
|
config => config.hostName !== hostname
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.reverseProxyConfigs.length !== initialCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,11 +1,13 @@
|
|||||||
// node native scope
|
// node native scope
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import * as tls from 'tls';
|
import * as tls from 'tls';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
|
|
||||||
export { http, https, net, tls, url };
|
|
||||||
|
export { EventEmitter, http, https, net, tls, url };
|
||||||
|
|
||||||
// tsclass scope
|
// tsclass scope
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
@ -22,9 +24,10 @@ import * as smartstring from '@push.rocks/smartstring';
|
|||||||
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
||||||
|
|
||||||
// third party scope
|
// third party scope
|
||||||
|
import * as acme from 'acme-client';
|
||||||
import prettyMs from 'pretty-ms';
|
import prettyMs from 'pretty-ms';
|
||||||
import * as ws from 'ws';
|
import * as ws from 'ws';
|
||||||
import wsDefault from 'ws';
|
import wsDefault from 'ws';
|
||||||
import { minimatch } from 'minimatch';
|
import { minimatch } from 'minimatch';
|
||||||
|
|
||||||
export { prettyMs, ws, wsDefault, minimatch };
|
export { acme, prettyMs, ws, wsDefault, minimatch };
|
||||||
|
Reference in New Issue
Block a user