Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
1718a3b2f2 | |||
568f77e65b | |||
e212dacbf3 | |||
eea8942670 | |||
0574331b91 | |||
06e6c2eb52 | |||
edd9db31c2 | |||
d4251b2cf9 | |||
4ccc1db8a2 | |||
7e3ed93bc9 | |||
fa793f2c4a | |||
fe8106f0c8 | |||
b317ab8b3a | |||
4fd5524a0f |
55
changelog.md
55
changelog.md
@ -1,5 +1,60 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "3.25.0",
|
||||
"version": "3.28.0",
|
||||
"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.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
126
readme.md
126
readme.md
@ -193,12 +193,14 @@ sequenceDiagram
|
||||
- **HTTPS Reverse Proxy** - Route traffic to backend services based on hostname with TLS termination
|
||||
- **WebSocket Support** - Full WebSocket proxying with heartbeat monitoring
|
||||
- **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
|
||||
- **Let's Encrypt Integration** - Automatic certificate management using ACME protocol
|
||||
- **IP Filtering** - Control access with IP allow/block lists using glob patterns
|
||||
- **IPTables Integration** - Direct manipulation of iptables for low-level port forwarding
|
||||
- **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
|
||||
|
||||
@ -275,18 +277,38 @@ const portProxy = new PortProxy({
|
||||
toPort: 8443,
|
||||
targetIP: 'localhost', // Default target host
|
||||
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 }],
|
||||
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: [
|
||||
{
|
||||
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
|
||||
allowedIPs: ['192.168.1.*'], // Restrict access by IP
|
||||
blockedIPs: ['192.168.1.100'], // Block specific IPs
|
||||
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
|
||||
});
|
||||
|
||||
@ -333,19 +355,31 @@ acmeHandler.addDomain('api.example.com');
|
||||
|
||||
### PortProxy Settings
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------------------------|--------------------------------------------------------|-------------|
|
||||
| `fromPort` | Port to listen on | - |
|
||||
| `toPort` | Destination port to forward to | - |
|
||||
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
|
||||
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
|
||||
| `defaultAllowedIPs` | IP patterns allowed by default | - |
|
||||
| `defaultBlockedIPs` | IP patterns blocked by default | - |
|
||||
| `preserveSourceIP` | Preserve the original client IP | false |
|
||||
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 600000 |
|
||||
| `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 |
|
||||
| Option | Description | Default |
|
||||
|---------------------------|--------------------------------------------------------|-------------|
|
||||
| `fromPort` | Port to listen on | - |
|
||||
| `toPort` | Destination port to forward to | - |
|
||||
| `targetIP` | Default destination IP if not specified in domainConfig | 'localhost' |
|
||||
| `sniEnabled` | Enable SNI inspection for TLS connections | false |
|
||||
| `defaultAllowedIPs` | IP patterns allowed by default | - |
|
||||
| `defaultBlockedIPs` | IP patterns blocked by default | - |
|
||||
| `preserveSourceIP` | Preserve the original client IP | false |
|
||||
| `maxConnectionLifetime` | Maximum time in ms to keep a connection open | 3600000 |
|
||||
| `initialDataTimeout` | Timeout for initial data/handshake in ms | 60000 |
|
||||
| `socketTimeout` | Socket inactivity timeout in ms | 3600000 |
|
||||
| `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
|
||||
|
||||
@ -359,14 +393,37 @@ acmeHandler.addDomain('api.example.com');
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
- Detailed logging of connection states
|
||||
- Termination statistics
|
||||
- Randomized timeouts to prevent "thundering herd" problems
|
||||
- Per-domain timeout configuration
|
||||
|
||||
### WebSocket Support
|
||||
|
||||
@ -385,6 +442,39 @@ The `PortProxy` class can inspect the SNI (Server Name Indication) field in TLS
|
||||
- Domain-specific allowed IP ranges
|
||||
- Protection against SNI renegotiation attacks
|
||||
|
||||
## 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
|
||||
|
||||
## 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.
|
||||
@ -402,4 +492,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
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 = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '3.25.0',
|
||||
version: '3.28.0',
|
||||
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.'
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import * as http from 'http';
|
||||
import * as acme from 'acme-client';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
/**
|
||||
* Represents a domain certificate with various status information
|
||||
*/
|
||||
interface IDomainCertificate {
|
||||
certObtained: boolean;
|
||||
obtainingInProgress: boolean;
|
||||
@ -8,27 +10,147 @@ interface IDomainCertificate {
|
||||
privateKey?: string;
|
||||
challengeToken?: string;
|
||||
challengeKeyAuthorization?: string;
|
||||
expiryDate?: Date;
|
||||
lastRenewalAttempt?: Date;
|
||||
}
|
||||
|
||||
export class Port80Handler {
|
||||
/**
|
||||
* Configuration options for the ACME Certificate Manager
|
||||
*/
|
||||
interface IAcmeCertManagerOptions {
|
||||
port?: number;
|
||||
contactEmail?: string;
|
||||
useProduction?: boolean;
|
||||
renewThresholdDays?: number;
|
||||
httpsRedirectPort?: number;
|
||||
renewCheckIntervalHours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: http.Server;
|
||||
private acmeClient: acme.Client | null = null;
|
||||
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>;
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* Creates a new ACME Certificate Manager
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: IAcmeCertManagerOptions = {}) {
|
||||
super();
|
||||
this.domainCertificates = new Map<string, IDomainCertificate>();
|
||||
|
||||
// Default options
|
||||
this.options = {
|
||||
port: options.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,
|
||||
};
|
||||
}
|
||||
|
||||
// Create and start an HTTP server on port 80.
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
this.server.listen(80, () => {
|
||||
console.log('Port80Handler is listening on port 80');
|
||||
/**
|
||||
* 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.
|
||||
* @param domain The domain to add.
|
||||
* Stops the HTTP server and renewal timer
|
||||
*/
|
||||
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 {
|
||||
if (!this.domainCertificates.has(domain)) {
|
||||
@ -38,55 +160,126 @@ export class Port80Handler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a domain from management.
|
||||
* @param domain The domain to remove.
|
||||
* Removes a domain from management
|
||||
* @param domain The domain to remove
|
||||
*/
|
||||
public removeDomain(domain: string): void {
|
||||
if (this.domainCertificates.delete(domain)) {
|
||||
console.log(`Domain removed: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a certificate for a domain directly (for externally obtained certificates)
|
||||
* @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
|
||||
*/
|
||||
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.
|
||||
* Uses Let’s Encrypt’s production directory (for testing you might switch to staging).
|
||||
* Lazy initialization of the ACME client
|
||||
* @returns An ACME client instance
|
||||
*/
|
||||
private async getAcmeClient(): Promise<acme.Client> {
|
||||
private async getAcmeClient(): Promise<plugins.acme.Client> {
|
||||
if (this.acmeClient) {
|
||||
return this.acmeClient;
|
||||
}
|
||||
// Generate a new account key and convert Buffer to string.
|
||||
this.accountKey = (await acme.forge.createPrivateKey()).toString();
|
||||
this.acmeClient = new acme.Client({
|
||||
directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate
|
||||
// For testing, you could use:
|
||||
// directoryUrl: acme.directory.letsencrypt.staging,
|
||||
|
||||
// Generate a new account key
|
||||
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
|
||||
|
||||
this.acmeClient = new plugins.acme.Client({
|
||||
directoryUrl: this.options.useProduction
|
||||
? plugins.acme.directory.letsencrypt.production
|
||||
: plugins.acme.directory.letsencrypt.staging,
|
||||
accountKey: this.accountKey,
|
||||
});
|
||||
// Create a new account. Make sure to update the contact email.
|
||||
|
||||
// Create a new account
|
||||
await this.acmeClient.createAccount({
|
||||
termsOfServiceAgreed: true,
|
||||
contact: ['mailto:admin@example.com'],
|
||||
contact: [`mailto:${this.options.contactEmail}`],
|
||||
});
|
||||
|
||||
return this.acmeClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming HTTP requests on port 80.
|
||||
* If the request is for an ACME challenge, it responds with the key authorization.
|
||||
* If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance.
|
||||
* Handles incoming HTTP requests
|
||||
* @param req The HTTP request
|
||||
* @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;
|
||||
if (!hostHeader) {
|
||||
res.statusCode = 400;
|
||||
res.end('Bad Request: Host header is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract domain (ignoring any port in the Host header)
|
||||
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/')) {
|
||||
this.handleAcmeChallenge(req, res, domain);
|
||||
return;
|
||||
@ -100,38 +293,47 @@ export class Port80Handler {
|
||||
|
||||
const domainInfo = this.domainCertificates.get(domain)!;
|
||||
|
||||
// If certificate exists, redirect to HTTPS on port 443.
|
||||
// If certificate exists, redirect to HTTPS
|
||||
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.setHeader('Location', redirectUrl);
|
||||
res.end(`Redirecting to ${redirectUrl}`);
|
||||
} else {
|
||||
// Trigger certificate issuance if not already running.
|
||||
// Trigger certificate issuance if not already running
|
||||
if (!domainInfo.obtainingInProgress) {
|
||||
domainInfo.obtainingInProgress = true;
|
||||
this.obtainCertificate(domain).catch(err => {
|
||||
this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message });
|
||||
console.error(`Error obtaining certificate for ${domain}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
res.statusCode = 503;
|
||||
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);
|
||||
if (!domainInfo) {
|
||||
res.statusCode = 404;
|
||||
res.end('Domain not configured');
|
||||
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 token = urlParts ? urlParts[urlParts.length - 1] : '';
|
||||
|
||||
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
||||
res.statusCode = 200;
|
||||
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.
|
||||
* On success, it stores the certificate and key in memory and clears challenge data.
|
||||
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
||||
* @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 {
|
||||
const client = await this.getAcmeClient();
|
||||
|
||||
// Create a new order for the domain.
|
||||
// Create a new order for the domain
|
||||
const order = await client.createOrder({
|
||||
identifiers: [{ type: 'dns', value: domain }],
|
||||
});
|
||||
|
||||
// Get the authorizations for the order.
|
||||
// Get the authorizations for the order
|
||||
const authorizations = await client.getAuthorizations(order);
|
||||
|
||||
for (const authz of authorizations) {
|
||||
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
||||
if (!challenge) {
|
||||
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 domainInfo = this.domainCertificates.get(domain)!;
|
||||
|
||||
// Store the challenge data
|
||||
domainInfo.challengeToken = challenge.token;
|
||||
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
||||
|
||||
// Notify the ACME server that the challenge is ready.
|
||||
// The acme-client examples show that verifyChallenge takes three arguments:
|
||||
// (authorization, challenge, keyAuthorization). However, the official TypeScript
|
||||
// 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);
|
||||
// Wait until the challenge is validated.
|
||||
await client.waitForValidStatus(challenge);
|
||||
console.log(`HTTP-01 challenge completed for ${domain}`);
|
||||
// ACME client type definition workaround - use compatible approach
|
||||
// First check if challenge verification is needed
|
||||
const authzUrl = authz.url;
|
||||
|
||||
try {
|
||||
// Check if authzUrl exists and perform verification
|
||||
if (authzUrl) {
|
||||
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.
|
||||
// Convert the resulting Buffers to strings.
|
||||
const [csrBuffer, privateKeyBuffer] = await acme.forge.createCsr({
|
||||
// Generate a CSR and private key
|
||||
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
|
||||
commonName: domain,
|
||||
});
|
||||
|
||||
const csr = csrBuffer.toString();
|
||||
const privateKey = privateKeyBuffer.toString();
|
||||
|
||||
// Finalize the order and obtain the certificate.
|
||||
// Finalize the order with our CSR
|
||||
await client.finalizeOrder(order, csr);
|
||||
|
||||
// Get the certificate with the full chain
|
||||
const certificate = await client.getCertificate(order);
|
||||
|
||||
const domainInfo = this.domainCertificates.get(domain)!;
|
||||
// Store the certificate and key
|
||||
domainInfo.certificate = certificate;
|
||||
domainInfo.privateKey = privateKey;
|
||||
domainInfo.certObtained = true;
|
||||
domainInfo.obtainingInProgress = false;
|
||||
|
||||
// Clear challenge data
|
||||
delete domainInfo.challengeToken;
|
||||
delete domainInfo.challengeKeyAuthorization;
|
||||
|
||||
// Extract expiry date from certificate
|
||||
try {
|
||||
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||
if (matches && matches[1]) {
|
||||
domainInfo.expiryDate = new Date(matches[1]);
|
||||
console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
||||
}
|
||||
|
||||
console.log(`Certificate obtained for ${domain}`);
|
||||
// In a production system, persist the certificate and key and reload your TLS server.
|
||||
} catch (error) {
|
||||
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||
const domainInfo = this.domainCertificates.get(domain);
|
||||
if (domainInfo) {
|
||||
domainInfo.obtainingInProgress = false;
|
||||
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);
|
||||
}
|
||||
}
|
@ -7,14 +7,10 @@ export interface IDomainConfig {
|
||||
blockedIPs?: string[]; // Glob patterns for blocked IPs
|
||||
targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
|
||||
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
|
||||
// Protocol-specific timeout overrides
|
||||
httpTimeout?: number; // HTTP connection timeout override (ms)
|
||||
wsTimeout?: number; // WebSocket connection timeout override (ms)
|
||||
// Allow domain-specific timeout override
|
||||
connectionTimeout?: number; // Connection timeout override (ms)
|
||||
}
|
||||
|
||||
/** Connection protocol types for timeout management */
|
||||
export type ProtocolType = 'http' | 'websocket' | 'https' | 'tls' | 'unknown';
|
||||
|
||||
/** Port proxy settings including global allowed port ranges */
|
||||
export interface IPortProxySettings extends plugins.tls.TlsOptions {
|
||||
fromPort: number;
|
||||
@ -26,40 +22,37 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
|
||||
defaultBlockedIPs?: string[];
|
||||
preserveSourceIP?: boolean;
|
||||
|
||||
// Updated timeout settings with better defaults
|
||||
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 15000 (15s)
|
||||
socketTimeout?: number; // Socket inactivity timeout (ms), default: 300000 (5m)
|
||||
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 30000 (30s)
|
||||
// Timeout settings
|
||||
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
|
||||
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
|
||||
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
|
||||
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h)
|
||||
inactivityTimeout?: number; // Inactivity timeout (ms), default: 3600000 (1h)
|
||||
|
||||
// Protocol-specific timeouts
|
||||
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h)
|
||||
httpConnectionTimeout?: number; // HTTP specific timeout (ms), default: 1800000 (30m)
|
||||
wsConnectionTimeout?: number; // WebSocket specific timeout (ms), default: 14400000 (4h)
|
||||
httpKeepAliveTimeout?: number; // HTTP keep-alive header timeout (ms), default: 1200000 (20m)
|
||||
|
||||
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
|
||||
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
|
||||
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
|
||||
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
|
||||
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
|
||||
|
||||
// Socket optimization settings
|
||||
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
|
||||
keepAlive?: boolean; // Enable TCP keepalive (default: true)
|
||||
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
|
||||
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
|
||||
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
|
||||
keepAlive?: boolean; // Enable TCP keepalive (default: true)
|
||||
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
|
||||
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
|
||||
|
||||
// Enable enhanced features
|
||||
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
|
||||
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
|
||||
enableProtocolDetection?: boolean; // Enable HTTP/WebSocket protocol detection
|
||||
enableDetailedLogging?: boolean; // Enable detailed connection logging
|
||||
// Enhanced features
|
||||
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
|
||||
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
|
||||
enableDetailedLogging?: boolean; // Enable detailed connection logging
|
||||
enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging
|
||||
enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd
|
||||
|
||||
// Rate limiting and security
|
||||
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
|
||||
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
|
||||
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced connection record with protocol-specific handling
|
||||
* Enhanced connection record
|
||||
*/
|
||||
interface IConnectionRecord {
|
||||
id: string; // Unique connection identifier
|
||||
@ -76,78 +69,161 @@ interface IConnectionRecord {
|
||||
pendingDataSize: number; // Track total size of pending data
|
||||
|
||||
// Enhanced tracking fields
|
||||
protocolType: ProtocolType; // Connection protocol type
|
||||
isPooledConnection: boolean; // Whether this is likely a browser pooled connection
|
||||
lastHttpRequest?: number; // Timestamp of last HTTP request (for keep-alive tracking)
|
||||
httpKeepAliveTimeout?: number; // HTTP keep-alive timeout from headers
|
||||
bytesReceived: number; // Total bytes received
|
||||
bytesSent: number; // Total bytes sent
|
||||
remoteIP: string; // Remote IP (cached for logging after socket close)
|
||||
localPort: number; // Local port (cached for logging)
|
||||
httpRequests: number; // Count of HTTP requests on this connection
|
||||
isTLS: boolean; // Whether this connection is a TLS connection
|
||||
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
|
||||
hasReceivedInitialData: boolean; // Whether initial data has been received
|
||||
domainConfig?: IDomainConfig; // Associated domain config for this connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
|
||||
* Enhanced for robustness and detailed logging.
|
||||
* @param buffer - Buffer containing the TLS ClientHello.
|
||||
* @param enableLogging - Whether to enable detailed logging.
|
||||
* @returns The server name if found, otherwise undefined.
|
||||
*/
|
||||
function extractSNI(buffer: Buffer): string | undefined {
|
||||
let offset = 0;
|
||||
if (buffer.length < 5) return undefined;
|
||||
|
||||
const recordType = buffer.readUInt8(0);
|
||||
if (recordType !== 22) return undefined; // 22 = handshake
|
||||
|
||||
const recordLength = buffer.readUInt16BE(3);
|
||||
if (buffer.length < 5 + recordLength) return undefined;
|
||||
|
||||
offset = 5;
|
||||
const handshakeType = buffer.readUInt8(offset);
|
||||
if (handshakeType !== 1) return undefined; // 1 = ClientHello
|
||||
|
||||
offset += 4; // Skip handshake header (type + length)
|
||||
offset += 2 + 32; // Skip client version and random
|
||||
|
||||
const sessionIDLength = buffer.readUInt8(offset);
|
||||
offset += 1 + sessionIDLength; // Skip session ID
|
||||
|
||||
const cipherSuitesLength = buffer.readUInt16BE(offset);
|
||||
offset += 2 + cipherSuitesLength; // Skip cipher suites
|
||||
|
||||
const compressionMethodsLength = buffer.readUInt8(offset);
|
||||
offset += 1 + compressionMethodsLength; // Skip compression methods
|
||||
|
||||
if (offset + 2 > buffer.length) return undefined;
|
||||
const extensionsLength = buffer.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
const extensionsEnd = offset + extensionsLength;
|
||||
|
||||
while (offset + 4 <= extensionsEnd) {
|
||||
const extensionType = buffer.readUInt16BE(offset);
|
||||
const extensionLength = buffer.readUInt16BE(offset + 2);
|
||||
offset += 4;
|
||||
if (extensionType === 0x0000) { // SNI extension
|
||||
if (offset + 2 > buffer.length) return undefined;
|
||||
const sniListLength = buffer.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
const sniListEnd = offset + sniListLength;
|
||||
while (offset + 3 < sniListEnd) {
|
||||
const nameType = buffer.readUInt8(offset++);
|
||||
const nameLen = buffer.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
if (nameType === 0) { // host_name
|
||||
if (offset + nameLen > buffer.length) return undefined;
|
||||
return buffer.toString('utf8', offset, offset + nameLen);
|
||||
}
|
||||
offset += nameLen;
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
offset += extensionLength;
|
||||
function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined {
|
||||
try {
|
||||
// Check if buffer is too small for TLS
|
||||
if (buffer.length < 5) {
|
||||
if (enableLogging) console.log("Buffer too small for TLS header");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check record type (has to be handshake - 22)
|
||||
const recordType = buffer.readUInt8(0);
|
||||
if (recordType !== 22) {
|
||||
if (enableLogging) console.log(`Not a TLS handshake. Record type: ${recordType}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check TLS version (has to be 3.1 or higher)
|
||||
const majorVersion = buffer.readUInt8(1);
|
||||
const minorVersion = buffer.readUInt8(2);
|
||||
if (enableLogging) console.log(`TLS Version: ${majorVersion}.${minorVersion}`);
|
||||
|
||||
// Check record length
|
||||
const recordLength = buffer.readUInt16BE(3);
|
||||
if (buffer.length < 5 + recordLength) {
|
||||
if (enableLogging) console.log(`Buffer too small for TLS record. Expected: ${5 + recordLength}, Got: ${buffer.length}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let offset = 5;
|
||||
const handshakeType = buffer.readUInt8(offset);
|
||||
if (handshakeType !== 1) {
|
||||
if (enableLogging) console.log(`Not a ClientHello. Handshake type: ${handshakeType}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
offset += 4; // Skip handshake header (type + length)
|
||||
|
||||
// Client version
|
||||
const clientMajorVersion = buffer.readUInt8(offset);
|
||||
const clientMinorVersion = buffer.readUInt8(offset + 1);
|
||||
if (enableLogging) console.log(`Client Version: ${clientMajorVersion}.${clientMinorVersion}`);
|
||||
|
||||
offset += 2 + 32; // Skip client version and random
|
||||
|
||||
// Session ID
|
||||
const sessionIDLength = buffer.readUInt8(offset);
|
||||
if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`);
|
||||
offset += 1 + sessionIDLength; // Skip session ID
|
||||
|
||||
// Cipher suites
|
||||
if (offset + 2 > buffer.length) {
|
||||
if (enableLogging) console.log("Buffer too small for cipher suites length");
|
||||
return undefined;
|
||||
}
|
||||
const cipherSuitesLength = buffer.readUInt16BE(offset);
|
||||
if (enableLogging) console.log(`Cipher Suites Length: ${cipherSuitesLength}`);
|
||||
offset += 2 + cipherSuitesLength; // Skip cipher suites
|
||||
|
||||
// Compression methods
|
||||
if (offset + 1 > buffer.length) {
|
||||
if (enableLogging) console.log("Buffer too small for compression methods length");
|
||||
return undefined;
|
||||
}
|
||||
const compressionMethodsLength = buffer.readUInt8(offset);
|
||||
if (enableLogging) console.log(`Compression Methods Length: ${compressionMethodsLength}`);
|
||||
offset += 1 + compressionMethodsLength; // Skip compression methods
|
||||
|
||||
// Extensions
|
||||
if (offset + 2 > buffer.length) {
|
||||
if (enableLogging) console.log("Buffer too small for extensions length");
|
||||
return undefined;
|
||||
}
|
||||
const extensionsLength = buffer.readUInt16BE(offset);
|
||||
if (enableLogging) console.log(`Extensions Length: ${extensionsLength}`);
|
||||
offset += 2;
|
||||
const extensionsEnd = offset + extensionsLength;
|
||||
|
||||
if (extensionsEnd > buffer.length) {
|
||||
if (enableLogging) console.log(`Buffer too small for extensions. Expected end: ${extensionsEnd}, Buffer length: ${buffer.length}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse extensions
|
||||
while (offset + 4 <= extensionsEnd) {
|
||||
const extensionType = buffer.readUInt16BE(offset);
|
||||
const extensionLength = buffer.readUInt16BE(offset + 2);
|
||||
|
||||
if (enableLogging) console.log(`Extension Type: 0x${extensionType.toString(16)}, Length: ${extensionLength}`);
|
||||
|
||||
offset += 4;
|
||||
|
||||
if (extensionType === 0x0000) { // SNI extension
|
||||
if (offset + 2 > buffer.length) {
|
||||
if (enableLogging) console.log("Buffer too small for SNI list length");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sniListLength = buffer.readUInt16BE(offset);
|
||||
if (enableLogging) console.log(`SNI List Length: ${sniListLength}`);
|
||||
offset += 2;
|
||||
const sniListEnd = offset + sniListLength;
|
||||
|
||||
if (sniListEnd > buffer.length) {
|
||||
if (enableLogging) console.log(`Buffer too small for SNI list. Expected end: ${sniListEnd}, Buffer length: ${buffer.length}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
while (offset + 3 < sniListEnd) {
|
||||
const nameType = buffer.readUInt8(offset++);
|
||||
const nameLen = buffer.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
if (enableLogging) console.log(`Name Type: ${nameType}, Name Length: ${nameLen}`);
|
||||
|
||||
if (nameType === 0) { // host_name
|
||||
if (offset + nameLen > buffer.length) {
|
||||
if (enableLogging) console.log(`Buffer too small for hostname. Expected: ${offset + nameLen}, Got: ${buffer.length}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serverName = buffer.toString('utf8', offset, offset + nameLen);
|
||||
if (enableLogging) console.log(`Extracted SNI: ${serverName}`);
|
||||
return serverName;
|
||||
}
|
||||
|
||||
offset += nameLen;
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
offset += extensionLength;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableLogging) console.log("No SNI extension found");
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
console.log(`Error extracting SNI: ${err}`);
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper: Check if a port falls within any of the given port ranges
|
||||
@ -157,7 +233,10 @@ const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }
|
||||
|
||||
// Helper: Check if a given IP matches any of the glob patterns
|
||||
const isAllowed = (ip: string, patterns: string[]): boolean => {
|
||||
if (!ip || !patterns || patterns.length === 0) return false;
|
||||
|
||||
const normalizeIP = (ip: string): string[] => {
|
||||
if (!ip) return [];
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
const ipv4 = ip.slice(7);
|
||||
return [ip, ipv4];
|
||||
@ -167,7 +246,10 @@ const isAllowed = (ip: string, patterns: string[]): boolean => {
|
||||
}
|
||||
return [ip];
|
||||
};
|
||||
|
||||
const normalizedIPVariants = normalizeIP(ip);
|
||||
if (normalizedIPVariants.length === 0) return false;
|
||||
|
||||
const expandedPatterns = patterns.flatMap(normalizeIP);
|
||||
return normalizedIPVariants.some(ipVariant =>
|
||||
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
|
||||
@ -176,6 +258,7 @@ const isAllowed = (ip: string, patterns: string[]): boolean => {
|
||||
|
||||
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns
|
||||
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
|
||||
if (!ip) return false;
|
||||
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
|
||||
return isAllowed(ip, allowed);
|
||||
};
|
||||
@ -185,34 +268,17 @@ const generateConnectionId = (): string => {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
};
|
||||
|
||||
// Protocol detection helpers
|
||||
const isHttpRequest = (buffer: Buffer): boolean => {
|
||||
if (buffer.length < 4) return false;
|
||||
const start = buffer.toString('ascii', 0, 4).toUpperCase();
|
||||
return (
|
||||
start.startsWith('GET ') ||
|
||||
start.startsWith('POST') ||
|
||||
start.startsWith('PUT ') ||
|
||||
start.startsWith('HEAD') ||
|
||||
start.startsWith('DELE') ||
|
||||
start.startsWith('PATC') ||
|
||||
start.startsWith('OPTI')
|
||||
);
|
||||
};
|
||||
|
||||
const isWebSocketUpgrade = (buffer: Buffer): boolean => {
|
||||
if (buffer.length < 20) return false;
|
||||
const data = buffer.toString('ascii', 0, Math.min(buffer.length, 200));
|
||||
return (
|
||||
data.includes('Upgrade: websocket') ||
|
||||
data.includes('Upgrade: WebSocket')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a buffer contains a TLS handshake
|
||||
const isTlsHandshake = (buffer: Buffer): boolean => {
|
||||
return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake
|
||||
};
|
||||
|
||||
// Helper: Generate a slightly randomized timeout to prevent thundering herd
|
||||
const randomizeTimeout = (baseTimeout: number, variationPercent: number = 5): number => {
|
||||
const variation = baseTimeout * (variationPercent / 100);
|
||||
return baseTimeout + Math.floor(Math.random() * variation * 2) - variation;
|
||||
};
|
||||
|
||||
export class PortProxy {
|
||||
private netServers: plugins.net.Server[] = [];
|
||||
settings: IPortProxySettings;
|
||||
@ -242,30 +308,27 @@ export class PortProxy {
|
||||
...settingsArg,
|
||||
targetIP: settingsArg.targetIP || 'localhost',
|
||||
|
||||
// Timeout settings with browser-friendly defaults
|
||||
initialDataTimeout: settingsArg.initialDataTimeout || 15000, // 15 seconds
|
||||
socketTimeout: settingsArg.socketTimeout || 300000, // 5 minutes
|
||||
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 30000, // 30 seconds
|
||||
|
||||
// Protocol-specific timeouts
|
||||
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default
|
||||
httpConnectionTimeout: settingsArg.httpConnectionTimeout || 1800000, // 30 minutes
|
||||
wsConnectionTimeout: settingsArg.wsConnectionTimeout || 14400000, // 4 hours
|
||||
httpKeepAliveTimeout: settingsArg.httpKeepAliveTimeout || 1200000, // 20 minutes
|
||||
// Timeout settings with our enhanced defaults
|
||||
initialDataTimeout: settingsArg.initialDataTimeout || 60000, // 60 seconds for initial data
|
||||
socketTimeout: settingsArg.socketTimeout || 3600000, // 1 hour socket timeout
|
||||
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, // 60 seconds interval
|
||||
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default lifetime
|
||||
inactivityTimeout: settingsArg.inactivityTimeout || 3600000, // 1 hour inactivity timeout
|
||||
|
||||
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds
|
||||
|
||||
// Socket optimization settings
|
||||
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
|
||||
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
|
||||
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 60000, // 1 minute
|
||||
maxPendingDataSize: settingsArg.maxPendingDataSize || 1024 * 1024, // 1MB
|
||||
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 30000, // 30 seconds
|
||||
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes
|
||||
|
||||
// Feature flags
|
||||
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
|
||||
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes || false,
|
||||
enableProtocolDetection: settingsArg.enableProtocolDetection !== undefined ? settingsArg.enableProtocolDetection : true,
|
||||
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
|
||||
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
|
||||
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || true,
|
||||
|
||||
// Rate limiting defaults
|
||||
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP
|
||||
@ -332,115 +395,22 @@ export class PortProxy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol-specific timeout based on connection type
|
||||
* Get connection timeout based on domain config or default settings
|
||||
*/
|
||||
private getProtocolTimeout(record: IConnectionRecord, domainConfig?: IDomainConfig): number {
|
||||
// If the protocol has a domain-specific timeout, use that
|
||||
if (domainConfig) {
|
||||
if (record.protocolType === 'http' && domainConfig.httpTimeout) {
|
||||
return domainConfig.httpTimeout;
|
||||
}
|
||||
if (record.protocolType === 'websocket' && domainConfig.wsTimeout) {
|
||||
return domainConfig.wsTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
// Use HTTP keep-alive timeout from headers if available
|
||||
if (record.httpKeepAliveTimeout) {
|
||||
return record.httpKeepAliveTimeout;
|
||||
private getConnectionTimeout(record: IConnectionRecord): number {
|
||||
// If the connection has a domain-specific timeout, use that
|
||||
if (record.domainConfig?.connectionTimeout) {
|
||||
return record.domainConfig.connectionTimeout;
|
||||
}
|
||||
|
||||
// Otherwise use default protocol-specific timeout
|
||||
switch (record.protocolType) {
|
||||
case 'http':
|
||||
return this.settings.httpConnectionTimeout!;
|
||||
case 'websocket':
|
||||
return this.settings.wsConnectionTimeout!;
|
||||
case 'https':
|
||||
case 'tls':
|
||||
return this.settings.httpConnectionTimeout!; // Use HTTP timeout for HTTPS by default
|
||||
default:
|
||||
return this.settings.maxConnectionLifetime!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect protocol and update connection record
|
||||
*/
|
||||
private detectProtocol(data: Buffer, record: IConnectionRecord): void {
|
||||
if (!this.settings.enableProtocolDetection || record.protocolType !== 'unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Detect TLS/HTTPS
|
||||
if (isTlsHandshake(data)) {
|
||||
record.protocolType = 'tls';
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(`[${record.id}] Protocol detected: TLS`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect HTTP including WebSocket upgrades
|
||||
if (isHttpRequest(data)) {
|
||||
record.httpRequests++;
|
||||
record.lastHttpRequest = Date.now();
|
||||
|
||||
// Check for WebSocket upgrade
|
||||
if (isWebSocketUpgrade(data)) {
|
||||
record.protocolType = 'websocket';
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(`[${record.id}] Protocol detected: WebSocket Upgrade`);
|
||||
}
|
||||
} else {
|
||||
record.protocolType = 'http';
|
||||
|
||||
// Parse HTTP keep-alive headers
|
||||
this.parseHttpHeaders(data, record);
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(`[${record.id}] Protocol detected: HTTP${record.isPooledConnection ? ' (KeepAlive)' : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[${record.id}] Error detecting protocol: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP headers for keep-alive and other connection info
|
||||
*/
|
||||
private parseHttpHeaders(data: Buffer, record: IConnectionRecord): void {
|
||||
try {
|
||||
const headerStr = data.toString('utf8', 0, Math.min(data.length, 1024));
|
||||
|
||||
// Check for HTTP keep-alive
|
||||
const connectionHeader = headerStr.match(/\r\nConnection:\s*([^\r\n]+)/i);
|
||||
if (connectionHeader && connectionHeader[1].toLowerCase().includes('keep-alive')) {
|
||||
record.isPooledConnection = true;
|
||||
|
||||
// Check for Keep-Alive timeout value
|
||||
const keepAliveHeader = headerStr.match(/\r\nKeep-Alive:\s*([^\r\n]+)/i);
|
||||
if (keepAliveHeader) {
|
||||
const timeoutMatch = keepAliveHeader[1].match(/timeout=(\d+)/i);
|
||||
if (timeoutMatch && timeoutMatch[1]) {
|
||||
const timeoutSec = parseInt(timeoutMatch[1], 10);
|
||||
if (!isNaN(timeoutSec) && timeoutSec > 0) {
|
||||
// Convert seconds to milliseconds and add some buffer
|
||||
record.httpKeepAliveTimeout = (timeoutSec * 1000) + 5000;
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(`[${record.id}] HTTP Keep-Alive timeout set to ${timeoutSec} seconds`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[${record.id}] Error parsing HTTP headers: ${err}`);
|
||||
// Use default timeout, potentially randomized
|
||||
const baseTimeout = this.settings.maxConnectionLifetime!;
|
||||
|
||||
if (this.settings.enableRandomizedTimeouts) {
|
||||
return randomizeTimeout(baseTimeout);
|
||||
}
|
||||
|
||||
return baseTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -465,7 +435,6 @@ export class PortProxy {
|
||||
const duration = Date.now() - record.incomingStartTime;
|
||||
const bytesReceived = record.bytesReceived;
|
||||
const bytesSent = record.bytesSent;
|
||||
const httpRequests = record.httpRequests;
|
||||
|
||||
try {
|
||||
if (!record.incoming.destroyed) {
|
||||
@ -538,7 +507,7 @@ export class PortProxy {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` +
|
||||
` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
|
||||
`HTTP Requests: ${httpRequests}, Protocol: ${record.protocolType}, Pooled: ${record.isPooledConnection}`);
|
||||
`TLS: ${record.isTLS ? 'Yes' : 'No'}`);
|
||||
} else {
|
||||
console.log(`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
|
||||
}
|
||||
@ -608,6 +577,21 @@ export class PortProxy {
|
||||
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
||||
}
|
||||
|
||||
// Apply enhanced TCP options if available
|
||||
if (this.settings.enableKeepAliveProbes) {
|
||||
try {
|
||||
// These are platform-specific and may not be available
|
||||
if ('setKeepAliveProbes' in socket) {
|
||||
(socket as any).setKeepAliveProbes(10);
|
||||
}
|
||||
if ('setKeepAliveInterval' in socket) {
|
||||
(socket as any).setKeepAliveInterval(1000);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors - these are optional enhancements
|
||||
}
|
||||
}
|
||||
|
||||
// Create a unique connection ID and record
|
||||
const connectionId = generateConnectionId();
|
||||
const connectionRecord: IConnectionRecord = {
|
||||
@ -621,13 +605,13 @@ export class PortProxy {
|
||||
pendingDataSize: 0,
|
||||
|
||||
// Initialize enhanced tracking fields
|
||||
protocolType: 'unknown',
|
||||
isPooledConnection: false,
|
||||
bytesReceived: 0,
|
||||
bytesSent: 0,
|
||||
remoteIP: remoteIP,
|
||||
localPort: localPort,
|
||||
httpRequests: 0
|
||||
isTLS: false,
|
||||
tlsHandshakeComplete: false,
|
||||
hasReceivedInitialData: false
|
||||
};
|
||||
|
||||
// Track connection by IP
|
||||
@ -685,9 +669,15 @@ export class PortProxy {
|
||||
socket.end();
|
||||
cleanupOnce();
|
||||
}
|
||||
}, this.settings.initialDataTimeout);
|
||||
}, this.settings.initialDataTimeout!);
|
||||
|
||||
// Make sure timeout doesn't keep the process alive
|
||||
if (initialTimeout.unref) {
|
||||
initialTimeout.unref();
|
||||
}
|
||||
} else {
|
||||
initialDataReceived = true;
|
||||
connectionRecord.hasReceivedInitialData = true;
|
||||
}
|
||||
|
||||
socket.on('error', (err: Error) => {
|
||||
@ -699,39 +689,14 @@ export class PortProxy {
|
||||
connectionRecord.bytesReceived += chunk.length;
|
||||
this.updateActivity(connectionRecord);
|
||||
|
||||
// Detect protocol on first data chunk
|
||||
if (connectionRecord.protocolType === 'unknown') {
|
||||
this.detectProtocol(chunk, connectionRecord);
|
||||
// Check for TLS handshake if this is the first chunk
|
||||
if (!connectionRecord.isTLS && isTlsHandshake(chunk)) {
|
||||
connectionRecord.isTLS = true;
|
||||
|
||||
// Update timeout based on protocol
|
||||
if (connectionRecord.cleanupTimer) {
|
||||
clearTimeout(connectionRecord.cleanupTimer);
|
||||
|
||||
// Set new timeout based on protocol
|
||||
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
|
||||
connectionRecord.cleanupTimer = setTimeout(() => {
|
||||
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
|
||||
initiateCleanupOnce(`${connectionRecord.protocolType}_timeout`);
|
||||
}, protocolTimeout);
|
||||
}
|
||||
} else if (connectionRecord.protocolType === 'http' && isHttpRequest(chunk)) {
|
||||
// Additional HTTP request on the same connection
|
||||
connectionRecord.httpRequests++;
|
||||
connectionRecord.lastHttpRequest = Date.now();
|
||||
|
||||
// Parse HTTP headers again for keep-alive changes
|
||||
this.parseHttpHeaders(chunk, connectionRecord);
|
||||
|
||||
// Update timeout based on new HTTP headers
|
||||
if (connectionRecord.cleanupTimer) {
|
||||
clearTimeout(connectionRecord.cleanupTimer);
|
||||
|
||||
// Set new timeout based on updated HTTP info
|
||||
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
|
||||
connectionRecord.cleanupTimer = setTimeout(() => {
|
||||
console.log(`[${connectionId}] HTTP connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
|
||||
initiateCleanupOnce('http_timeout');
|
||||
}, protocolTimeout);
|
||||
if (this.settings.enableTlsDebugLogging) {
|
||||
console.log(`[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes`);
|
||||
// Try to extract SNI and log detailed debug info
|
||||
extractSNI(chunk, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -797,9 +762,17 @@ export class PortProxy {
|
||||
initialTimeout = null;
|
||||
}
|
||||
|
||||
// Detect protocol if initial chunk is available
|
||||
if (initialChunk && this.settings.enableProtocolDetection) {
|
||||
this.detectProtocol(initialChunk, connectionRecord);
|
||||
// Mark that we've received initial data
|
||||
initialDataReceived = true;
|
||||
connectionRecord.hasReceivedInitialData = true;
|
||||
|
||||
// Check if this looks like a TLS handshake
|
||||
if (initialChunk && isTlsHandshake(initialChunk)) {
|
||||
connectionRecord.isTLS = true;
|
||||
|
||||
if (this.settings.enableTlsDebugLogging) {
|
||||
console.log(`[${connectionId}] TLS handshake detected in setup, ${initialChunk.length} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
|
||||
@ -809,6 +782,9 @@ export class PortProxy {
|
||||
config.domains.some(d => plugins.minimatch(serverName, d))
|
||||
) : undefined);
|
||||
|
||||
// Save domain config in connection record
|
||||
connectionRecord.domainConfig = domainConfig;
|
||||
|
||||
// IP validation is skipped if allowedIPs is empty
|
||||
if (domainConfig) {
|
||||
const effectiveAllowedIPs: string[] = [
|
||||
@ -847,9 +823,13 @@ export class PortProxy {
|
||||
// Track bytes received
|
||||
connectionRecord.bytesReceived += chunk.length;
|
||||
|
||||
// Detect protocol even during connection setup
|
||||
if (this.settings.enableProtocolDetection && connectionRecord.protocolType === 'unknown') {
|
||||
this.detectProtocol(chunk, connectionRecord);
|
||||
// Check for TLS handshake
|
||||
if (!connectionRecord.isTLS && isTlsHandshake(chunk)) {
|
||||
connectionRecord.isTLS = true;
|
||||
|
||||
if (this.settings.enableTlsDebugLogging) {
|
||||
console.log(`[${connectionId}] TLS handshake detected in tempDataHandler, ${chunk.length} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if adding this chunk would exceed the buffer limit
|
||||
@ -888,6 +868,20 @@ export class PortProxy {
|
||||
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
||||
}
|
||||
|
||||
// Apply enhanced TCP options if available
|
||||
if (this.settings.enableKeepAliveProbes) {
|
||||
try {
|
||||
if ('setKeepAliveProbes' in targetSocket) {
|
||||
(targetSocket as any).setKeepAliveProbes(10);
|
||||
}
|
||||
if ('setKeepAliveInterval' in targetSocket) {
|
||||
(targetSocket as any).setKeepAliveInterval(1000);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors - these are optional enhancements
|
||||
}
|
||||
}
|
||||
|
||||
// Setup specific error handler for connection phase
|
||||
targetSocket.once('error', (err) => {
|
||||
// This handler runs only once during the initial connection phase
|
||||
@ -928,7 +922,7 @@ export class PortProxy {
|
||||
|
||||
// Handle timeouts
|
||||
socket.on('timeout', () => {
|
||||
console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
|
||||
console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`);
|
||||
if (incomingTerminationReason === null) {
|
||||
incomingTerminationReason = 'timeout';
|
||||
this.incrementTerminationStat('incoming', 'timeout');
|
||||
@ -937,7 +931,7 @@ export class PortProxy {
|
||||
});
|
||||
|
||||
targetSocket.on('timeout', () => {
|
||||
console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
|
||||
console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`);
|
||||
if (outgoingTerminationReason === null) {
|
||||
outgoingTerminationReason = 'timeout';
|
||||
this.incrementTerminationStat('outgoing', 'timeout');
|
||||
@ -946,8 +940,8 @@ export class PortProxy {
|
||||
});
|
||||
|
||||
// Set appropriate timeouts using the configured value
|
||||
socket.setTimeout(this.settings.socketTimeout || 300000);
|
||||
targetSocket.setTimeout(this.settings.socketTimeout || 300000);
|
||||
socket.setTimeout(this.settings.socketTimeout || 3600000);
|
||||
targetSocket.setTimeout(this.settings.socketTimeout || 3600000);
|
||||
|
||||
// Track outgoing data for bytes counting
|
||||
targetSocket.on('data', (chunk: Buffer) => {
|
||||
@ -984,7 +978,7 @@ export class PortProxy {
|
||||
console.log(
|
||||
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
||||
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
|
||||
` Protocol: ${connectionRecord.protocolType}`
|
||||
` TLS: ${connectionRecord.isTLS ? 'Yes' : 'No'}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
@ -1003,7 +997,7 @@ export class PortProxy {
|
||||
console.log(
|
||||
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
||||
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
|
||||
` Protocol: ${connectionRecord.protocolType}`
|
||||
` TLS: ${connectionRecord.isTLS ? 'Yes' : 'No'}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
@ -1023,7 +1017,7 @@ export class PortProxy {
|
||||
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
|
||||
try {
|
||||
// Try to extract SNI from potential renegotiation
|
||||
const newSNI = extractSNI(renegChunk);
|
||||
const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
|
||||
if (newSNI && newSNI !== connectionRecord.lockedDomain) {
|
||||
console.log(`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
|
||||
initiateCleanupOnce('sni_mismatch');
|
||||
@ -1037,17 +1031,31 @@ export class PortProxy {
|
||||
});
|
||||
}
|
||||
|
||||
// Set protocol-specific timeout based on detected protocol
|
||||
// Set connection timeout
|
||||
if (connectionRecord.cleanupTimer) {
|
||||
clearTimeout(connectionRecord.cleanupTimer);
|
||||
}
|
||||
|
||||
// Set timeout based on protocol
|
||||
const protocolTimeout = this.getProtocolTimeout(connectionRecord, domainConfig);
|
||||
// Set timeout based on domain config or default
|
||||
const connectionTimeout = this.getConnectionTimeout(connectionRecord);
|
||||
connectionRecord.cleanupTimer = setTimeout(() => {
|
||||
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection exceeded max lifetime (${plugins.prettyMs(protocolTimeout)}), forcing cleanup.`);
|
||||
initiateCleanupOnce(`${connectionRecord.protocolType}_max_lifetime`);
|
||||
}, protocolTimeout);
|
||||
console.log(`[${connectionId}] Connection from ${remoteIP} exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.`);
|
||||
initiateCleanupOnce('connection_timeout');
|
||||
}, connectionTimeout);
|
||||
|
||||
// Make sure timeout doesn't keep the process alive
|
||||
if (connectionRecord.cleanupTimer.unref) {
|
||||
connectionRecord.cleanupTimer.unref();
|
||||
}
|
||||
|
||||
// Mark TLS handshake as complete for TLS connections
|
||||
if (connectionRecord.isTLS) {
|
||||
connectionRecord.tlsHandshakeComplete = true;
|
||||
|
||||
if (this.settings.enableTlsDebugLogging) {
|
||||
console.log(`[${connectionId}] TLS handshake complete for connection from ${remoteIP}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -1055,7 +1063,7 @@ export class PortProxy {
|
||||
// Only apply port-based rules if the incoming port is within one of the global port ranges.
|
||||
if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) {
|
||||
if (this.settings.forwardAllGlobalRanges) {
|
||||
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
||||
if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0 && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
||||
console.log(`[${connectionId}] Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
|
||||
socket.end();
|
||||
return;
|
||||
@ -1111,7 +1119,20 @@ export class PortProxy {
|
||||
}
|
||||
|
||||
initialDataReceived = true;
|
||||
const serverName = extractSNI(chunk) || '';
|
||||
|
||||
// Try to extract SNI
|
||||
let serverName = '';
|
||||
|
||||
if (isTlsHandshake(chunk)) {
|
||||
connectionRecord.isTLS = true;
|
||||
|
||||
if (this.settings.enableTlsDebugLogging) {
|
||||
console.log(`[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes`);
|
||||
}
|
||||
|
||||
serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || '';
|
||||
}
|
||||
|
||||
// Lock the connection to the negotiated SNI.
|
||||
connectionRecord.lockedDomain = serverName;
|
||||
|
||||
@ -1123,9 +1144,12 @@ export class PortProxy {
|
||||
});
|
||||
} else {
|
||||
initialDataReceived = true;
|
||||
if (!this.settings.defaultAllowedIPs || this.settings.defaultAllowedIPs.length === 0 || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
||||
connectionRecord.hasReceivedInitialData = true;
|
||||
|
||||
if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0 && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
||||
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
|
||||
}
|
||||
|
||||
setupConnection('');
|
||||
}
|
||||
};
|
||||
@ -1167,11 +1191,10 @@ export class PortProxy {
|
||||
const now = Date.now();
|
||||
let maxIncoming = 0;
|
||||
let maxOutgoing = 0;
|
||||
let httpConnections = 0;
|
||||
let wsConnections = 0;
|
||||
let tlsConnections = 0;
|
||||
let unknownConnections = 0;
|
||||
let pooledConnections = 0;
|
||||
let nonTlsConnections = 0;
|
||||
let completedTlsHandshakes = 0;
|
||||
let pendingTlsHandshakes = 0;
|
||||
|
||||
// Create a copy of the keys to avoid modification during iteration
|
||||
const connectionIds = [...this.connectionRecords.keys()];
|
||||
@ -1180,17 +1203,16 @@ export class PortProxy {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (!record) continue;
|
||||
|
||||
// Track connection stats by protocol
|
||||
switch (record.protocolType) {
|
||||
case 'http': httpConnections++; break;
|
||||
case 'websocket': wsConnections++; break;
|
||||
case 'tls':
|
||||
case 'https': tlsConnections++; break;
|
||||
default: unknownConnections++; break;
|
||||
}
|
||||
|
||||
if (record.isPooledConnection) {
|
||||
pooledConnections++;
|
||||
// Track connection stats
|
||||
if (record.isTLS) {
|
||||
tlsConnections++;
|
||||
if (record.tlsHandshakeComplete) {
|
||||
completedTlsHandshakes++;
|
||||
} else {
|
||||
pendingTlsHandshakes++;
|
||||
}
|
||||
} else {
|
||||
nonTlsConnections++;
|
||||
}
|
||||
|
||||
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
|
||||
@ -1202,29 +1224,26 @@ export class PortProxy {
|
||||
if (record.outgoingClosedTime &&
|
||||
!record.incoming.destroyed &&
|
||||
!record.connectionClosed &&
|
||||
(now - record.outgoingClosedTime > 30000)) {
|
||||
(now - record.outgoingClosedTime > 120000)) {
|
||||
const remoteIP = record.remoteIP;
|
||||
console.log(`[${id}] Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing closed.`);
|
||||
this.cleanupConnection(record, 'parity_check');
|
||||
}
|
||||
|
||||
// Check for stalled connections waiting for initial data
|
||||
if (!record.hasReceivedInitialData &&
|
||||
(now - record.incomingStartTime > this.settings.initialDataTimeout! / 2)) {
|
||||
console.log(`[${id}] Warning: Connection from ${record.remoteIP} has not received initial data after ${plugins.prettyMs(now - record.incomingStartTime)}`);
|
||||
}
|
||||
|
||||
// Skip inactivity check if disabled
|
||||
if (!this.settings.disableInactivityCheck) {
|
||||
// Inactivity check - use protocol-specific values
|
||||
let inactivityThreshold = 180000; // 3 minutes default
|
||||
|
||||
// Set protocol-specific inactivity thresholds
|
||||
if (record.protocolType === 'http' && record.isPooledConnection) {
|
||||
inactivityThreshold = this.settings.httpKeepAliveTimeout || 1200000; // 20 minutes for pooled HTTP
|
||||
} else if (record.protocolType === 'websocket') {
|
||||
inactivityThreshold = this.settings.wsConnectionTimeout || 14400000; // 4 hours for WebSocket
|
||||
} else if (record.protocolType === 'http') {
|
||||
inactivityThreshold = this.settings.httpConnectionTimeout || 1800000; // 30 minutes for HTTP
|
||||
}
|
||||
// Inactivity check with configurable timeout
|
||||
const inactivityThreshold = this.settings.inactivityTimeout!;
|
||||
|
||||
const inactivityTime = now - record.lastActivity;
|
||||
if (inactivityTime > inactivityThreshold && !record.connectionClosed) {
|
||||
console.log(`[${id}] Inactivity check: No activity on ${record.protocolType} connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
|
||||
console.log(`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
|
||||
this.cleanupConnection(record, 'inactivity');
|
||||
}
|
||||
}
|
||||
@ -1233,11 +1252,11 @@ export class PortProxy {
|
||||
// Log detailed stats periodically
|
||||
console.log(
|
||||
`Active connections: ${this.connectionRecords.size}. ` +
|
||||
`Types: HTTP=${httpConnections}, WS=${wsConnections}, TLS=${tlsConnections}, Unknown=${unknownConnections}, Pooled=${pooledConnections}. ` +
|
||||
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), Non-TLS=${nonTlsConnections}. ` +
|
||||
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` +
|
||||
`Termination stats: ${JSON.stringify({IN: this.terminationStats.incoming, OUT: this.terminationStats.outgoing})}`
|
||||
);
|
||||
}, this.settings.inactivityCheckInterval || 30000);
|
||||
}, this.settings.inactivityCheckInterval || 60000);
|
||||
|
||||
// Make sure the interval doesn't keep the process alive
|
||||
if (this.connectionLogger.unref) {
|
||||
|
@ -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 {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* sets a new set of reverse configs to be routed to
|
||||
* @param reverseCandidatesArg
|
||||
*/
|
||||
public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]) {
|
||||
this.reverseProxyConfigs = reverseCandidatesArg;
|
||||
constructor(
|
||||
configs?: tsclass.network.IReverseProxyConfig[],
|
||||
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;
|
||||
}
|
||||
) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
// Strip port from host if present
|
||||
const hostWithoutPort = originalHost.split(':')[0];
|
||||
const correspodingReverseProxyConfig = this.reverseProxyConfigs.find((reverseConfig) => {
|
||||
return reverseConfig.hostName === hostWithoutPort;
|
||||
|
||||
// First try configs with path patterns
|
||||
const configsWithPaths = configs.filter(config => this.pathPatterns.has(config));
|
||||
|
||||
// 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
|
||||
import { EventEmitter } from 'events';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
import * as url from 'url';
|
||||
|
||||
export { http, https, net, tls, url };
|
||||
|
||||
export { EventEmitter, http, https, net, tls, url };
|
||||
|
||||
// tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
@ -22,9 +24,10 @@ import * as smartstring from '@push.rocks/smartstring';
|
||||
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
||||
|
||||
// third party scope
|
||||
import * as acme from 'acme-client';
|
||||
import prettyMs from 'pretty-ms';
|
||||
import * as ws from 'ws';
|
||||
import wsDefault from 'ws';
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
export { prettyMs, ws, wsDefault, minimatch };
|
||||
export { acme, prettyMs, ws, wsDefault, minimatch };
|
||||
|
Reference in New Issue
Block a user