Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
48c5ea3b1d | |||
bd9292bf47 | |||
6532e6f0e0 | |||
8791da83b4 | |||
9ad08edf79 | |||
c0de8c59a2 | |||
3748689c16 | |||
d0b3139fda | |||
fd4f731ada | |||
ced9b5b27b | |||
eb70a86304 | |||
131d9d326e | |||
12de96a7d5 | |||
296e1fcdc7 | |||
8459e4013c | |||
191c8ac0e6 | |||
3ab483d164 | |||
fcd80dc56b | |||
8ddffcd6e5 | |||
a5a7781c17 | |||
d647e77cdf | |||
9161336197 |
78
changelog.md
78
changelog.md
@ -1,5 +1,83 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-03-05 - 3.23.1 - fix(PortProxy)
|
||||||
|
Enhanced connection setup to handle pending data buffering before establishing outgoing connection
|
||||||
|
|
||||||
|
- Introduced pending data buffering to address issues with data reception before outgoing connection is fully established.
|
||||||
|
- Removed immediate data piping in favor of buffering to ensure complete initial data transfer.
|
||||||
|
- Added temporary data handler to collect incoming data during connection setup for precise activity tracking.
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.23.0 - feat(documentation)
|
||||||
|
Updated documentation with architecture flow diagrams.
|
||||||
|
|
||||||
|
- Added detailed architecture and flow diagrams for SmartProxy components.
|
||||||
|
- Included HTTPS Reverse Proxy Flow diagram.
|
||||||
|
- Integrated Port Proxy with SNI-based Routing diagram.
|
||||||
|
- Added Let's Encrypt Certificate Acquisition flow.
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.22.5 - fix(documentation)
|
||||||
|
Refactored readme for clarity and consistency, fixed documentation typos
|
||||||
|
|
||||||
|
- Updated readme to improve clarity and remove redundant information.
|
||||||
|
- Fixed minor documentation issues in the code comments.
|
||||||
|
- Reorganized readme structure for better readability.
|
||||||
|
- Improved sample code snippets for easier understanding.
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.22.4 - fix(core)
|
||||||
|
Addressed minor issues in the core modules to improve stability and performance.
|
||||||
|
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.22.3 - fix(core)
|
||||||
|
Improve connection management and error handling in PortProxy
|
||||||
|
|
||||||
|
- Refactored connection cleanup to handle errors more gracefully.
|
||||||
|
- Introduced comprehensive comments for better code understanding.
|
||||||
|
- Revised SNI data timeout logic for connection handling.
|
||||||
|
- Enhanced logging and error reporting during connection management.
|
||||||
|
- Improved inactivity checks and parity checks for existing connections.
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.22.2 - fix(portproxy)
|
||||||
|
Refactored connection cleanup logic in PortProxy
|
||||||
|
|
||||||
|
- Simplified the connection cleanup logic by removing redundant methods.
|
||||||
|
- Consolidated the cleanup initiation and execution into a single cleanup method.
|
||||||
|
- Improved error handling by ensuring connections are closed appropriately.
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.22.1 - fix(PortProxy)
|
||||||
|
Fix connection timeout and IP validation handling for PortProxy
|
||||||
|
|
||||||
|
- Adjusted initial data timeout setting for SNI-enabled connections in PortProxy.
|
||||||
|
- Restored IP validation logic to original behavior, ensuring compatibility with domain configurations.
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.22.0 - feat(classes.portproxy)
|
||||||
|
Enhanced PortProxy to support initial data timeout and improved IP handling
|
||||||
|
|
||||||
|
- Added `initialDataTimeout` to PortProxy settings for handling data flow in chained proxies.
|
||||||
|
- Improved IP validation by allowing relaxed checks in chained proxy setups.
|
||||||
|
- Introduced dynamic logging for connection lifecycle and proxy configurations.
|
||||||
|
- Enhanced timeout handling for better proxy resilience.
|
||||||
|
|
||||||
|
## 2025-03-03 - 3.21.0 - feat(PortProxy)
|
||||||
|
Enhancements to connection management in PortProxy
|
||||||
|
|
||||||
|
- Introduced a unique ID for each connection record for improved tracking.
|
||||||
|
- Enhanced cleanup mechanism for connections with dual states: initiated and executed.
|
||||||
|
- Implemented shutdown process handling to ensure graceful connection closure.
|
||||||
|
- Added logging for better tracing of connection activities and states.
|
||||||
|
- Improved connection setup with explicit timeouts and data flow management.
|
||||||
|
- Integrated inactivity and parity checks to monitor connection health.
|
||||||
|
|
||||||
|
## 2025-03-01 - 3.20.2 - fix(PortProxy)
|
||||||
|
Enhance connection cleanup handling in PortProxy
|
||||||
|
|
||||||
|
- Add checks to ensure timers are reset only if outgoing socket is active
|
||||||
|
- Prevent setting outgoingActive if the connection is already closed
|
||||||
|
|
||||||
|
## 2025-03-01 - 3.20.1 - fix(PortProxy)
|
||||||
|
Improve IP allowance check for forced domains
|
||||||
|
|
||||||
|
- Enhanced IP allowance check logic by incorporating blocked IPs and default allowed IPs for forced domains within port proxy configurations.
|
||||||
|
|
||||||
## 2025-03-01 - 3.20.0 - feat(PortProxy)
|
## 2025-03-01 - 3.20.0 - feat(PortProxy)
|
||||||
Enhance PortProxy with advanced connection cleanup and logging
|
Enhance PortProxy with advanced connection cleanup and logging
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "3.20.0",
|
"version": "3.23.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
|
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
491
readme.md
491
readme.md
@ -1,228 +1,389 @@
|
|||||||
# @push.rocks/smartproxy
|
# @push.rocks/smartproxy
|
||||||
|
|
||||||
A robust and versatile proxy package designed to handle high workloads, offering features like SSL redirection, port proxying, WebSocket support, and customizable routing and authentication.
|
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.
|
||||||
|
|
||||||
## Install
|
## Architecture & Flow Diagrams
|
||||||
|
|
||||||
To install `@push.rocks/smartproxy`, run the following command in your project's root directory:
|
### Component Architecture
|
||||||
|
The diagram below illustrates the main components of SmartProxy and how they interact:
|
||||||
|
|
||||||
```bash
|
```mermaid
|
||||||
npm install @push.rocks/smartproxy --save
|
flowchart TB
|
||||||
|
Client([Client])
|
||||||
|
|
||||||
|
subgraph "SmartProxy Components"
|
||||||
|
direction TB
|
||||||
|
HTTP80[HTTP Port 80\nSslRedirect]
|
||||||
|
HTTPS443[HTTPS Port 443\nNetworkProxy]
|
||||||
|
PortProxy[TCP Port Proxy\nwith SNI routing]
|
||||||
|
IPTables[IPTablesProxy]
|
||||||
|
Router[ProxyRouter]
|
||||||
|
ACME[Port80Handler\nACME/Let's Encrypt]
|
||||||
|
Certs[(SSL Certificates)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Backend Services"
|
||||||
|
Service1[Service 1]
|
||||||
|
Service2[Service 2]
|
||||||
|
Service3[Service 3]
|
||||||
|
end
|
||||||
|
|
||||||
|
Client -->|HTTP Request| HTTP80
|
||||||
|
HTTP80 -->|Redirect| Client
|
||||||
|
Client -->|HTTPS Request| HTTPS443
|
||||||
|
Client -->|TLS/TCP| PortProxy
|
||||||
|
|
||||||
|
HTTPS443 -->|Route Request| Router
|
||||||
|
Router -->|Proxy Request| Service1
|
||||||
|
Router -->|Proxy Request| Service2
|
||||||
|
|
||||||
|
PortProxy -->|Direct TCP| Service2
|
||||||
|
PortProxy -->|Direct TCP| Service3
|
||||||
|
|
||||||
|
IPTables -.->|Low-level forwarding| PortProxy
|
||||||
|
|
||||||
|
HTTP80 -.->|Challenge Response| ACME
|
||||||
|
ACME -.->|Generate/Manage| Certs
|
||||||
|
Certs -.->|Provide TLS Certs| HTTPS443
|
||||||
|
|
||||||
|
classDef component fill:#f9f,stroke:#333,stroke-width:2px;
|
||||||
|
classDef backend fill:#bbf,stroke:#333,stroke-width:1px;
|
||||||
|
classDef client fill:#dfd,stroke:#333,stroke-width:2px;
|
||||||
|
|
||||||
|
class Client client;
|
||||||
|
class HTTP80,HTTPS443,PortProxy,IPTables,Router,ACME component;
|
||||||
|
class Service1,Service2,Service3 backend;
|
||||||
```
|
```
|
||||||
|
|
||||||
This will add `@push.rocks/smartproxy` to your project's dependencies.
|
### HTTPS Reverse Proxy Flow
|
||||||
|
This diagram shows how HTTPS requests are handled and proxied to backend services:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant NetworkProxy
|
||||||
|
participant ProxyRouter
|
||||||
|
participant Backend
|
||||||
|
|
||||||
|
Client->>NetworkProxy: HTTPS Request
|
||||||
|
|
||||||
|
Note over NetworkProxy: TLS Termination
|
||||||
|
|
||||||
|
NetworkProxy->>ProxyRouter: Route Request
|
||||||
|
ProxyRouter->>ProxyRouter: Match hostname to config
|
||||||
|
|
||||||
|
alt Authentication Required
|
||||||
|
NetworkProxy->>Client: Request Authentication
|
||||||
|
Client->>NetworkProxy: Send Credentials
|
||||||
|
NetworkProxy->>NetworkProxy: Validate Credentials
|
||||||
|
end
|
||||||
|
|
||||||
|
NetworkProxy->>Backend: Forward Request
|
||||||
|
Backend->>NetworkProxy: Response
|
||||||
|
|
||||||
|
Note over NetworkProxy: Add Default Headers
|
||||||
|
|
||||||
|
NetworkProxy->>Client: Forward Response
|
||||||
|
|
||||||
|
alt WebSocket Request
|
||||||
|
Client->>NetworkProxy: Upgrade to WebSocket
|
||||||
|
NetworkProxy->>Backend: Upgrade to WebSocket
|
||||||
|
loop WebSocket Active
|
||||||
|
Client->>NetworkProxy: WebSocket Message
|
||||||
|
NetworkProxy->>Backend: Forward Message
|
||||||
|
Backend->>NetworkProxy: WebSocket Message
|
||||||
|
NetworkProxy->>Client: Forward Message
|
||||||
|
NetworkProxy-->>NetworkProxy: Heartbeat Check
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Proxy with SNI-based Routing
|
||||||
|
This diagram illustrates how TCP connections with SNI (Server Name Indication) are processed and forwarded:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant PortProxy
|
||||||
|
participant Backend
|
||||||
|
|
||||||
|
Client->>PortProxy: TLS Connection
|
||||||
|
|
||||||
|
alt SNI Enabled
|
||||||
|
PortProxy->>Client: Accept Connection
|
||||||
|
Client->>PortProxy: TLS ClientHello with SNI
|
||||||
|
PortProxy->>PortProxy: Extract SNI Hostname
|
||||||
|
PortProxy->>PortProxy: Match Domain Config
|
||||||
|
PortProxy->>PortProxy: Validate Client IP
|
||||||
|
|
||||||
|
alt IP Allowed
|
||||||
|
PortProxy->>Backend: Forward Connection
|
||||||
|
Note over PortProxy,Backend: Bidirectional Data Flow
|
||||||
|
else IP Rejected
|
||||||
|
PortProxy->>Client: Close Connection
|
||||||
|
end
|
||||||
|
else Port-based Routing
|
||||||
|
PortProxy->>PortProxy: Match Port Range
|
||||||
|
PortProxy->>PortProxy: Find Domain Config
|
||||||
|
PortProxy->>PortProxy: Validate Client IP
|
||||||
|
|
||||||
|
alt IP Allowed
|
||||||
|
PortProxy->>Backend: Forward Connection
|
||||||
|
Note over PortProxy,Backend: Bidirectional Data Flow
|
||||||
|
else IP Rejected
|
||||||
|
PortProxy->>Client: Close Connection
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
loop Connection Active
|
||||||
|
PortProxy-->>PortProxy: Monitor Activity
|
||||||
|
PortProxy-->>PortProxy: Check Max Lifetime
|
||||||
|
alt Inactivity or Max Lifetime Exceeded
|
||||||
|
PortProxy->>Client: Close Connection
|
||||||
|
PortProxy->>Backend: Close Connection
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Let's Encrypt Certificate Acquisition
|
||||||
|
This diagram shows how certificates are automatically acquired through the ACME protocol:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant Port80Handler
|
||||||
|
participant ACME as Let's Encrypt ACME
|
||||||
|
participant NetworkProxy
|
||||||
|
|
||||||
|
Client->>Port80Handler: HTTP Request for domain
|
||||||
|
|
||||||
|
alt Certificate Exists
|
||||||
|
Port80Handler->>Client: Redirect to HTTPS
|
||||||
|
else No Certificate
|
||||||
|
Port80Handler->>Port80Handler: Mark domain as obtaining cert
|
||||||
|
Port80Handler->>ACME: Create account & new order
|
||||||
|
ACME->>Port80Handler: Challenge information
|
||||||
|
|
||||||
|
Port80Handler->>Port80Handler: Store challenge token & key authorization
|
||||||
|
|
||||||
|
ACME->>Port80Handler: HTTP-01 Challenge Request
|
||||||
|
Port80Handler->>ACME: Challenge Response
|
||||||
|
|
||||||
|
ACME->>ACME: Validate domain ownership
|
||||||
|
ACME->>Port80Handler: Challenge validated
|
||||||
|
|
||||||
|
Port80Handler->>Port80Handler: Generate CSR
|
||||||
|
Port80Handler->>ACME: Submit CSR
|
||||||
|
ACME->>Port80Handler: Issue Certificate
|
||||||
|
|
||||||
|
Port80Handler->>Port80Handler: Store certificate & private key
|
||||||
|
Port80Handler->>Port80Handler: Mark certificate as obtained
|
||||||
|
|
||||||
|
Note over Port80Handler,NetworkProxy: Certificate available for use
|
||||||
|
|
||||||
|
Client->>Port80Handler: Another HTTP Request
|
||||||
|
Port80Handler->>Client: Redirect to HTTPS
|
||||||
|
Client->>NetworkProxy: HTTPS Request
|
||||||
|
Note over NetworkProxy: Uses new certificate
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @push.rocks/smartproxy
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
`@push.rocks/smartproxy` is a comprehensive package that provides advanced functionalities for handling proxy tasks efficiently, including SSL redirection, port proxying, WebSocket support, and dynamic routing with authentication capabilities. Here is an extensive guide on how to utilize these features effectively, ensuring robust and secure proxy operations.
|
### Basic Reverse Proxy Setup
|
||||||
|
|
||||||
### Initial Setup
|
|
||||||
|
|
||||||
Before exploring the advanced features of `smartproxy`, you need to set up a basic proxy server. This setup serves as the foundation for incorporating additional functionalities later on:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
import { NetworkProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Create an instance of NetworkProxy with the desired configuration
|
// Create a reverse proxy listening on port 443
|
||||||
const myNetworkProxy = new NetworkProxy({ port: 443 });
|
const proxy = new NetworkProxy({
|
||||||
|
port: 443
|
||||||
|
});
|
||||||
|
|
||||||
// Define reverse proxy configurations for the domains you wish to proxy
|
// Define reverse proxy configurations
|
||||||
const proxyConfigs = [
|
const proxyConfigs = [
|
||||||
{
|
{
|
||||||
destinationIp: '127.0.0.1',
|
|
||||||
destinationPort: '3000',
|
|
||||||
hostName: 'example.com',
|
hostName: 'example.com',
|
||||||
privateKey: `-----BEGIN PRIVATE KEY-----
|
destinationIp: '127.0.0.1',
|
||||||
PRIVATE_KEY_CONTENT
|
destinationPort: 3000,
|
||||||
-----END PRIVATE KEY-----`,
|
publicKey: 'your-cert-content',
|
||||||
publicKey: `-----BEGIN CERTIFICATE-----
|
privateKey: 'your-key-content'
|
||||||
CERTIFICATE_CONTENT
|
|
||||||
-----END CERTIFICATE-----`,
|
|
||||||
},
|
},
|
||||||
// Additional configurations can be added here
|
{
|
||||||
|
hostName: 'api.example.com',
|
||||||
|
destinationIp: '127.0.0.1',
|
||||||
|
destinationPort: 4000,
|
||||||
|
publicKey: 'your-cert-content',
|
||||||
|
privateKey: 'your-key-content',
|
||||||
|
// Optional basic auth
|
||||||
|
authentication: {
|
||||||
|
type: 'Basic',
|
||||||
|
user: 'admin',
|
||||||
|
pass: 'secret'
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Start the network proxy to enable forwarding
|
// Start the proxy and update configurations
|
||||||
await myNetworkProxy.start();
|
(async () => {
|
||||||
|
await proxy.start();
|
||||||
// Apply the configurations you defined earlier
|
await proxy.updateProxyConfigs(proxyConfigs);
|
||||||
await myNetworkProxy.updateProxyConfigs(proxyConfigs);
|
|
||||||
|
// Add default headers to all responses
|
||||||
// Optionally, you can set default headers to be included in all responses
|
await proxy.addDefaultHeaders({
|
||||||
await myNetworkProxy.addDefaultHeaders({
|
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'
|
||||||
'X-Powered-By': 'smartproxy',
|
});
|
||||||
});
|
})();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuring SSL Redirection
|
### HTTP to HTTPS Redirection
|
||||||
|
|
||||||
A critical feature of modern proxy servers is the ability to redirect HTTP traffic to secure HTTPS endpoints. The `SslRedirect` class in `smartproxy` simplifies this process by automatically redirecting requests from HTTP port 80 to HTTPS:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SslRedirect } from '@push.rocks/smartproxy';
|
import { SslRedirect } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Create an SslRedirect instance to listen on port 80
|
// Create and start HTTP to HTTPS redirect service on port 80
|
||||||
const mySslRedirect = new SslRedirect(80);
|
const redirector = new SslRedirect(80);
|
||||||
|
redirector.start();
|
||||||
// Start the redirect to enforce HTTPS
|
|
||||||
await mySslRedirect.start();
|
|
||||||
|
|
||||||
// To stop HTTP redirection, use the following command:
|
|
||||||
await mySslRedirect.stop();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Managing Port Proxying
|
### TCP Port Forwarding with Domain-based Routing
|
||||||
|
|
||||||
Port proxying is essential for forwarding traffic from one port to another, an important feature for services that require dynamic port changes without downtime. Smartproxy's `PortProxy` class efficiently handles these scenarios:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { PortProxy } from '@push.rocks/smartproxy';
|
import { PortProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Set up a PortProxy to forward traffic from port 5000 to 3000
|
// Configure port proxy with domain-based routing
|
||||||
const myPortProxy = new PortProxy(5000, 3000);
|
const portProxy = new PortProxy({
|
||||||
|
fromPort: 443,
|
||||||
// Initiate the port proxy
|
toPort: 8443,
|
||||||
await myPortProxy.start();
|
targetIP: 'localhost', // Default target host
|
||||||
|
sniEnabled: true, // Enable SNI inspection
|
||||||
// To halt the port proxy, execute:
|
globalPortRanges: [{ from: 443, to: 443 }],
|
||||||
await myPortProxy.stop();
|
defaultAllowedIPs: ['*'], // Allow all IPs by default
|
||||||
```
|
domainConfigs: [
|
||||||
|
|
||||||
For more intricate setups—such as forwarding based on specific domain rules or IP allowances—smartproxy allows detailed configurations:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { PortProxy } from '@push.rocks/smartproxy';
|
|
||||||
|
|
||||||
// Configure complex port proxy rules
|
|
||||||
const advancedPortProxy = new PortProxy({
|
|
||||||
fromPort: 6000,
|
|
||||||
toPort: 3000,
|
|
||||||
domains: [
|
|
||||||
{
|
{
|
||||||
domain: 'api.example.com',
|
domains: ['example.com', '*.example.com'], // Glob patterns for matching domains
|
||||||
allowedIPs: ['192.168.0.*', '127.0.0.1'],
|
allowedIPs: ['192.168.1.*'], // Restrict access by IP
|
||||||
targetIP: '192.168.1.100'
|
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 }]
|
||||||
}
|
}
|
||||||
// Additional domain rules can be added as needed
|
|
||||||
],
|
],
|
||||||
sniEnabled: true, // Server Name Indication (SNI) support
|
maxConnectionLifetime: 3600000, // 1 hour in milliseconds
|
||||||
defaultAllowedIPs: ['*'],
|
preserveSourceIP: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate the proxy with conditional rules
|
portProxy.start();
|
||||||
await advancedPortProxy.start();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### WebSocket Handling
|
### IPTables Port Forwarding
|
||||||
|
|
||||||
With real-time applications becoming more prevalent, effective WebSocket handling is crucial in a proxy server. Smartproxy natively incorporates WebSocket support to manage WebSocket traffic securely and efficiently:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
|
||||||
|
|
||||||
// Create a NetworkProxy instance for WebSocket traffic
|
|
||||||
const wsNetworkProxy = new NetworkProxy({ port: 443 });
|
|
||||||
|
|
||||||
// Define proxy configurations targeted for WebSocket traffic
|
|
||||||
const websocketConfig = [
|
|
||||||
{
|
|
||||||
destinationIp: '127.0.0.1',
|
|
||||||
destinationPort: '8080',
|
|
||||||
hostName: 'socket.example.com',
|
|
||||||
// Include SSL details if necessary
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Start the proxy and apply WebSocket settings
|
|
||||||
await wsNetworkProxy.start();
|
|
||||||
await wsNetworkProxy.updateProxyConfigs(websocketConfig);
|
|
||||||
|
|
||||||
// Set heartbeat intervals to maintain WebSocket connections
|
|
||||||
wsNetworkProxy.heartbeatInterval = setInterval(() => {
|
|
||||||
// Logic for connection health checks
|
|
||||||
}, 60000); // every minute
|
|
||||||
|
|
||||||
// Capture and handle server errors for resiliency
|
|
||||||
wsNetworkProxy.httpsServer.on('error', (error) => console.log('Server Error:', error));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Routing and Custom Features
|
|
||||||
|
|
||||||
Smartproxy shines with its dynamic routing capabilities, allowing for custom and advanced request routing based on the request's destination. This enables extensive flexibility, such as directing API requests or facilitating intricate B2B integrations:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
|
||||||
|
|
||||||
// Instantiate a proxy with dynamic routing
|
|
||||||
const routeProxy = new NetworkProxy({ port: 8443 });
|
|
||||||
|
|
||||||
routeProxy.router.setNewProxyConfigs([
|
|
||||||
{
|
|
||||||
destinationIp: '192.168.1.150',
|
|
||||||
destinationPort: '80',
|
|
||||||
hostName: 'dynamic.example.com',
|
|
||||||
authentication: {
|
|
||||||
type: 'Basic',
|
|
||||||
user: 'admin',
|
|
||||||
pass: 'password123'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Activate the routing proxy
|
|
||||||
await routeProxy.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
For those who require granular traffic control, integrating tools like `iptables` offers additional power over network management:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { IPTablesProxy } from '@push.rocks/smartproxy';
|
import { IPTablesProxy } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// Set up IPTables for sophisticated network traffic management
|
// Configure IPTables to forward from port 80 to 8080
|
||||||
const iptablesProxy = new IPTablesProxy({
|
const iptables = new IPTablesProxy({
|
||||||
fromPort: 8081,
|
fromPort: 80,
|
||||||
toPort: 8080,
|
toPort: 8080,
|
||||||
deleteOnExit: true // Clean up rules when the server shuts down
|
toHost: 'localhost',
|
||||||
|
preserveSourceIP: true,
|
||||||
|
deleteOnExit: true // Automatically clean up rules on process exit
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enable routing through IPTables
|
iptables.start();
|
||||||
await iptablesProxy.start();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integrating SSL and HTTP/HTTPS Credentials
|
### Automatic HTTPS Certificate Management
|
||||||
|
|
||||||
Handling sensitive data like SSL keys and certificates securely is crucial in proxy configurations:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { loadDefaultCertificates } from '@push.rocks/smartproxy';
|
import { Port80Handler } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
try {
|
// Create an ACME handler for Let's Encrypt
|
||||||
const { privateKey, publicKey } = loadDefaultCertificates(); // Adjust path if necessary
|
const acmeHandler = new Port80Handler();
|
||||||
console.log('SSL certificates loaded successfully.');
|
|
||||||
// Use these credentials in your configurations
|
// Add domains to manage certificates for
|
||||||
} catch (error) {
|
acmeHandler.addDomain('example.com');
|
||||||
console.error('Error loading certificates:', error);
|
acmeHandler.addDomain('api.example.com');
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing and Validation
|
## Configuration Options
|
||||||
|
|
||||||
Smartproxy supports extensive testing to ensure your proxy configurations operate as expected. Leveraging `tap` alongside TypeScript testing frameworks supports quality assurance:
|
### NetworkProxy Options
|
||||||
|
|
||||||
```typescript
|
| Option | Description | Default |
|
||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
|----------------|---------------------------------------------------|---------|
|
||||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
| `port` | Port to listen on for HTTPS connections | - |
|
||||||
|
|
||||||
tap.test('Check proxied request returns status 200', async () => {
|
### PortProxy Settings
|
||||||
// Testing logic
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.start();
|
| 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 |
|
||||||
|
|
||||||
### Conclusion
|
### IPTablesProxy Settings
|
||||||
|
|
||||||
`@push.rocks/smartproxy` is designed for both simple and complex proxying demands, offering tools for high-performance and secure proxy management across diverse environments. Its efficient configurations are capable of supporting SSL redirection, WebSocket traffic, dynamic routing, and other advanced functionalities, making it indispensable for developers seeking robust and adaptable proxy solutions. By integrating these capabilities with ease of use, `smartproxy` stands out as an essential tool in modern software architecture.
|
| Option | Description | Default |
|
||||||
|
|-------------------|---------------------------------------------|-------------|
|
||||||
|
| `fromPort` | Source port to forward from | - |
|
||||||
|
| `toPort` | Destination port to forward to | - |
|
||||||
|
| `toHost` | Destination host to forward to | 'localhost' |
|
||||||
|
| `preserveSourceIP`| Preserve the original client IP | false |
|
||||||
|
| `deleteOnExit` | Remove iptables rules when process exits | false |
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Connection Management and Monitoring
|
||||||
|
|
||||||
|
The `PortProxy` class includes built-in connection tracking and monitoring:
|
||||||
|
|
||||||
|
- Automatic cleanup of idle connections
|
||||||
|
- Timeouts for connections that exceed maximum lifetime
|
||||||
|
- Detailed logging of connection states
|
||||||
|
- Termination statistics
|
||||||
|
|
||||||
|
### WebSocket Support
|
||||||
|
|
||||||
|
The `NetworkProxy` class provides WebSocket support with:
|
||||||
|
|
||||||
|
- WebSocket connection proxying
|
||||||
|
- Automatic heartbeat monitoring
|
||||||
|
- Connection cleanup for inactive WebSockets
|
||||||
|
|
||||||
|
### SNI-based Routing
|
||||||
|
|
||||||
|
The `PortProxy` class can inspect the SNI (Server Name Indication) field in TLS handshakes to route connections based on the requested domain:
|
||||||
|
|
||||||
|
- Multiple backend targets per domain
|
||||||
|
- Round-robin load balancing
|
||||||
|
- Domain-specific allowed IP ranges
|
||||||
|
- Protection against SNI renegotiation attacks
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '3.20.0',
|
version: '3.23.1',
|
||||||
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
|
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
|
|||||||
maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections
|
maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections
|
||||||
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
|
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
|
||||||
|
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -88,22 +89,25 @@ function extractSNI(buffer: Buffer): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface IConnectionRecord {
|
interface IConnectionRecord {
|
||||||
|
id: string; // Unique connection identifier
|
||||||
incoming: plugins.net.Socket;
|
incoming: plugins.net.Socket;
|
||||||
outgoing: plugins.net.Socket | null;
|
outgoing: plugins.net.Socket | null;
|
||||||
incomingStartTime: number;
|
incomingStartTime: number;
|
||||||
outgoingStartTime?: number;
|
outgoingStartTime?: number;
|
||||||
outgoingClosedTime?: number;
|
outgoingClosedTime?: number;
|
||||||
lockedDomain?: string; // New field to lock this connection to the initial SNI
|
lockedDomain?: string; // Used to lock this connection to the initial SNI
|
||||||
connectionClosed: boolean;
|
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
|
||||||
cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity
|
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
|
||||||
|
lastActivity: number; // Last activity timestamp for inactivity detection
|
||||||
|
pendingData: Buffer[]; // Buffer to hold data during connection setup
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Check if a port falls within any of the given port ranges.
|
// Helper: Check if a port falls within any of the given port ranges
|
||||||
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
|
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
|
||||||
return ranges.some(range => port >= range.from && port <= range.to);
|
return ranges.some(range => port >= range.from && port <= range.to);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: Check if a given IP matches any of the glob patterns.
|
// Helper: Check if a given IP matches any of the glob patterns
|
||||||
const isAllowed = (ip: string, patterns: string[]): boolean => {
|
const isAllowed = (ip: string, patterns: string[]): boolean => {
|
||||||
const normalizeIP = (ip: string): string[] => {
|
const normalizeIP = (ip: string): string[] => {
|
||||||
if (ip.startsWith('::ffff:')) {
|
if (ip.startsWith('::ffff:')) {
|
||||||
@ -122,20 +126,25 @@ const isAllowed = (ip: string, patterns: string[]): boolean => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns.
|
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns
|
||||||
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
|
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
|
||||||
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
|
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
|
||||||
return isAllowed(ip, allowed);
|
return isAllowed(ip, allowed);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper: Generate a unique connection ID
|
||||||
|
const generateConnectionId = (): string => {
|
||||||
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||||
|
};
|
||||||
|
|
||||||
export class PortProxy {
|
export class PortProxy {
|
||||||
private netServers: plugins.net.Server[] = [];
|
private netServers: plugins.net.Server[] = [];
|
||||||
settings: IPortProxySettings;
|
settings: IPortProxySettings;
|
||||||
// Unified record tracking each connection pair.
|
private connectionRecords: Map<string, IConnectionRecord> = new Map();
|
||||||
private connectionRecords: Set<IConnectionRecord> = new Set();
|
|
||||||
private connectionLogger: NodeJS.Timeout | null = null;
|
private connectionLogger: NodeJS.Timeout | null = null;
|
||||||
|
private isShuttingDown: boolean = false;
|
||||||
|
|
||||||
// Map to track round robin indices for each domain config.
|
// Map to track round robin indices for each domain config
|
||||||
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
|
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
|
||||||
|
|
||||||
private terminationStats: {
|
private terminationStats: {
|
||||||
@ -151,6 +160,7 @@ export class PortProxy {
|
|||||||
...settingsArg,
|
...settingsArg,
|
||||||
targetIP: settingsArg.targetIP || 'localhost',
|
targetIP: settingsArg.targetIP || 'localhost',
|
||||||
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
|
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
|
||||||
|
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,32 +169,66 @@ export class PortProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans up a connection record if not already cleaned up.
|
* Cleans up a connection record.
|
||||||
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
|
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
|
||||||
* Logs the cleanup event.
|
* @param record - The connection record to clean up
|
||||||
|
* @param reason - Optional reason for cleanup (for logging)
|
||||||
*/
|
*/
|
||||||
private cleanupConnection(record: IConnectionRecord, special: boolean = false): void {
|
private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||||
if (!record.connectionClosed) {
|
if (!record.connectionClosed) {
|
||||||
record.connectionClosed = true;
|
record.connectionClosed = true;
|
||||||
|
|
||||||
if (record.cleanupTimer) {
|
if (record.cleanupTimer) {
|
||||||
clearTimeout(record.cleanupTimer);
|
clearTimeout(record.cleanupTimer);
|
||||||
|
record.cleanupTimer = undefined;
|
||||||
}
|
}
|
||||||
if (!record.incoming.destroyed) {
|
|
||||||
record.incoming.destroy();
|
try {
|
||||||
|
if (!record.incoming.destroyed) {
|
||||||
|
// Try graceful shutdown first, then force destroy after a short timeout
|
||||||
|
record.incoming.end();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (record && !record.incoming.destroyed) {
|
||||||
|
record.incoming.destroy();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error closing incoming socket: ${err}`);
|
||||||
|
if (!record.incoming.destroyed) {
|
||||||
|
record.incoming.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (record.outgoing && !record.outgoing.destroyed) {
|
|
||||||
record.outgoing.destroy();
|
try {
|
||||||
|
if (record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
// Try graceful shutdown first, then force destroy after a short timeout
|
||||||
|
record.outgoing.end();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (record && record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
record.outgoing.destroy();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error closing outgoing socket: ${err}`);
|
||||||
|
if (record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
record.outgoing.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.connectionRecords.delete(record);
|
|
||||||
|
// Remove the record from the tracking map
|
||||||
|
this.connectionRecords.delete(record.id);
|
||||||
|
|
||||||
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
||||||
if (special) {
|
console.log(`Connection from ${remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
|
||||||
console.log(`Special parity cleanup: Connection from ${remoteIP} cleaned up due to duration difference.`);
|
|
||||||
} else {
|
|
||||||
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateActivity(record: IConnectionRecord): void {
|
||||||
|
record.lastActivity = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
private getTargetIP(domainConfig: IDomainConfig): string {
|
private getTargetIP(domainConfig: IDomainConfig): string {
|
||||||
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
|
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
|
||||||
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
|
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
|
||||||
@ -198,27 +242,45 @@ export class PortProxy {
|
|||||||
public async start() {
|
public async start() {
|
||||||
// Define a unified connection handler for all listening ports.
|
// Define a unified connection handler for all listening ports.
|
||||||
const connectionHandler = (socket: plugins.net.Socket) => {
|
const connectionHandler = (socket: plugins.net.Socket) => {
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
socket.end();
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const remoteIP = socket.remoteAddress || '';
|
const remoteIP = socket.remoteAddress || '';
|
||||||
const localPort = socket.localPort; // The port on which this connection was accepted.
|
const localPort = socket.localPort; // The port on which this connection was accepted.
|
||||||
|
|
||||||
|
const connectionId = generateConnectionId();
|
||||||
const connectionRecord: IConnectionRecord = {
|
const connectionRecord: IConnectionRecord = {
|
||||||
|
id: connectionId,
|
||||||
incoming: socket,
|
incoming: socket,
|
||||||
outgoing: null,
|
outgoing: null,
|
||||||
incomingStartTime: Date.now(),
|
incomingStartTime: Date.now(),
|
||||||
|
lastActivity: Date.now(),
|
||||||
connectionClosed: false,
|
connectionClosed: false,
|
||||||
|
pendingData: [] // Initialize buffer for pending data
|
||||||
};
|
};
|
||||||
this.connectionRecords.add(connectionRecord);
|
this.connectionRecords.set(connectionId, connectionRecord);
|
||||||
|
|
||||||
console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
|
console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
|
||||||
|
|
||||||
let initialDataReceived = false;
|
let initialDataReceived = false;
|
||||||
let incomingTerminationReason: string | null = null;
|
let incomingTerminationReason: string | null = null;
|
||||||
let outgoingTerminationReason: string | null = null;
|
let outgoingTerminationReason: string | null = null;
|
||||||
|
|
||||||
// Local cleanup function that delegates to the class method.
|
// Local function for cleanupOnce
|
||||||
const cleanupOnce = () => {
|
const cleanupOnce = () => {
|
||||||
this.cleanupConnection(connectionRecord);
|
this.cleanupConnection(connectionRecord);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Define initiateCleanupOnce for compatibility with potential future improvements
|
||||||
|
const initiateCleanupOnce = (reason: string = 'normal') => {
|
||||||
|
console.log(`Connection cleanup initiated for ${remoteIP} (${reason})`);
|
||||||
|
cleanupOnce();
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to reject an incoming connection.
|
// Helper to reject an incoming connection
|
||||||
const rejectIncomingConnection = (reason: string, logMessage: string) => {
|
const rejectIncomingConnection = (reason: string, logMessage: string) => {
|
||||||
console.log(logMessage);
|
console.log(logMessage);
|
||||||
socket.end();
|
socket.end();
|
||||||
@ -229,11 +291,22 @@ export class PortProxy {
|
|||||||
cleanupOnce();
|
cleanupOnce();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set an initial timeout for SNI data if needed
|
||||||
|
let initialTimeout: NodeJS.Timeout | null = null;
|
||||||
|
if (this.settings.sniEnabled) {
|
||||||
|
initialTimeout = setTimeout(() => {
|
||||||
|
if (!initialDataReceived) {
|
||||||
|
console.log(`Initial data timeout for ${remoteIP}`);
|
||||||
|
socket.end();
|
||||||
|
cleanupOnce();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
initialDataReceived = true;
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('error', (err: Error) => {
|
socket.on('error', (err: Error) => {
|
||||||
const errorMessage = initialDataReceived
|
console.log(`Incoming socket error from ${remoteIP}: ${err.message}`);
|
||||||
? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`
|
|
||||||
: `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`;
|
|
||||||
console.log(errorMessage);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
|
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
|
||||||
@ -252,7 +325,7 @@ export class PortProxy {
|
|||||||
outgoingTerminationReason = reason;
|
outgoingTerminationReason = reason;
|
||||||
this.incrementTerminationStat('outgoing', reason);
|
this.incrementTerminationStat('outgoing', reason);
|
||||||
}
|
}
|
||||||
cleanupOnce();
|
initiateCleanupOnce(reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = (side: 'incoming' | 'outgoing') => () => {
|
const handleClose = (side: 'incoming' | 'outgoing') => () => {
|
||||||
@ -266,7 +339,7 @@ export class PortProxy {
|
|||||||
// Record the time when outgoing socket closed.
|
// Record the time when outgoing socket closed.
|
||||||
connectionRecord.outgoingClosedTime = Date.now();
|
connectionRecord.outgoingClosedTime = Date.now();
|
||||||
}
|
}
|
||||||
cleanupOnce();
|
initiateCleanupOnce('closed_' + side);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -274,9 +347,15 @@ export class PortProxy {
|
|||||||
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
|
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
|
||||||
* @param initialChunk - Optional initial data chunk.
|
* @param initialChunk - Optional initial data chunk.
|
||||||
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
|
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
|
||||||
* @param overridePort - If provided, use this port for the outgoing connection (typically the same as the incoming port).
|
* @param overridePort - If provided, use this port for the outgoing connection.
|
||||||
*/
|
*/
|
||||||
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
|
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
|
||||||
|
// Clear the initial timeout since we've received data
|
||||||
|
if (initialTimeout) {
|
||||||
|
clearTimeout(initialTimeout);
|
||||||
|
initialTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
|
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
|
||||||
const domainConfig = forcedDomain
|
const domainConfig = forcedDomain
|
||||||
? forcedDomain
|
? forcedDomain
|
||||||
@ -284,7 +363,7 @@ export class PortProxy {
|
|||||||
config.domains.some(d => plugins.minimatch(serverName, d))
|
config.domains.some(d => plugins.minimatch(serverName, d))
|
||||||
) : undefined);
|
) : undefined);
|
||||||
|
|
||||||
// Effective IP check: merge allowed IPs with default allowed, and remove blocked IPs.
|
// IP validation is skipped if allowedIPs is empty
|
||||||
if (domainConfig) {
|
if (domainConfig) {
|
||||||
const effectiveAllowedIPs: string[] = [
|
const effectiveAllowedIPs: string[] = [
|
||||||
...domainConfig.allowedIPs,
|
...domainConfig.allowedIPs,
|
||||||
@ -294,10 +373,12 @@ export class PortProxy {
|
|||||||
...(domainConfig.blockedIPs || []),
|
...(domainConfig.blockedIPs || []),
|
||||||
...(this.settings.defaultBlockedIPs || [])
|
...(this.settings.defaultBlockedIPs || [])
|
||||||
];
|
];
|
||||||
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
|
||||||
|
// Skip IP validation if allowedIPs is empty
|
||||||
|
if (domainConfig.allowedIPs.length > 0 && !isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
||||||
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
|
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
|
||||||
}
|
}
|
||||||
} else if (this.settings.defaultAllowedIPs) {
|
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
|
||||||
if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
|
if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
|
||||||
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
|
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
|
||||||
}
|
}
|
||||||
@ -312,34 +393,39 @@ export class PortProxy {
|
|||||||
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
|
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temporary handler to collect data during connection setup
|
||||||
|
const tempDataHandler = (chunk: Buffer) => {
|
||||||
|
connectionRecord.pendingData.push(Buffer.from(chunk));
|
||||||
|
this.updateActivity(connectionRecord);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the temp handler to capture all incoming data during connection setup
|
||||||
|
socket.on('data', tempDataHandler);
|
||||||
|
|
||||||
|
// Add initial chunk to pending data if present
|
||||||
|
if (initialChunk) {
|
||||||
|
connectionRecord.pendingData.push(Buffer.from(initialChunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the target socket but don't set up piping immediately
|
||||||
const targetSocket = plugins.net.connect(connectionOptions);
|
const targetSocket = plugins.net.connect(connectionOptions);
|
||||||
connectionRecord.outgoing = targetSocket;
|
connectionRecord.outgoing = targetSocket;
|
||||||
connectionRecord.outgoingStartTime = Date.now();
|
connectionRecord.outgoingStartTime = Date.now();
|
||||||
|
|
||||||
console.log(
|
// Setup error handlers immediately
|
||||||
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
|
||||||
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (initialChunk) {
|
|
||||||
socket.unshift(initialChunk);
|
|
||||||
}
|
|
||||||
socket.setTimeout(120000);
|
|
||||||
socket.pipe(targetSocket);
|
|
||||||
targetSocket.pipe(socket);
|
|
||||||
|
|
||||||
// Attach error and close handlers.
|
|
||||||
socket.on('error', handleError('incoming'));
|
socket.on('error', handleError('incoming'));
|
||||||
targetSocket.on('error', handleError('outgoing'));
|
targetSocket.on('error', handleError('outgoing'));
|
||||||
socket.on('close', handleClose('incoming'));
|
socket.on('close', handleClose('incoming'));
|
||||||
targetSocket.on('close', handleClose('outgoing'));
|
targetSocket.on('close', handleClose('outgoing'));
|
||||||
|
|
||||||
|
// Handle timeouts
|
||||||
socket.on('timeout', () => {
|
socket.on('timeout', () => {
|
||||||
console.log(`Timeout on incoming side from ${remoteIP}`);
|
console.log(`Timeout on incoming side from ${remoteIP}`);
|
||||||
if (incomingTerminationReason === null) {
|
if (incomingTerminationReason === null) {
|
||||||
incomingTerminationReason = 'timeout';
|
incomingTerminationReason = 'timeout';
|
||||||
this.incrementTerminationStat('incoming', 'timeout');
|
this.incrementTerminationStat('incoming', 'timeout');
|
||||||
}
|
}
|
||||||
cleanupOnce();
|
initiateCleanupOnce('timeout_incoming');
|
||||||
});
|
});
|
||||||
targetSocket.on('timeout', () => {
|
targetSocket.on('timeout', () => {
|
||||||
console.log(`Timeout on outgoing side from ${remoteIP}`);
|
console.log(`Timeout on outgoing side from ${remoteIP}`);
|
||||||
@ -347,45 +433,87 @@ export class PortProxy {
|
|||||||
outgoingTerminationReason = 'timeout';
|
outgoingTerminationReason = 'timeout';
|
||||||
this.incrementTerminationStat('outgoing', 'timeout');
|
this.incrementTerminationStat('outgoing', 'timeout');
|
||||||
}
|
}
|
||||||
cleanupOnce();
|
initiateCleanupOnce('timeout_outgoing');
|
||||||
});
|
});
|
||||||
socket.on('end', handleClose('incoming'));
|
|
||||||
targetSocket.on('end', handleClose('outgoing'));
|
|
||||||
|
|
||||||
// Initialize a cleanup timer for max connection lifetime.
|
// Set appropriate timeouts
|
||||||
if (this.settings.maxConnectionLifetime) {
|
socket.setTimeout(120000);
|
||||||
let incomingActive = false;
|
targetSocket.setTimeout(120000);
|
||||||
let outgoingActive = false;
|
|
||||||
const resetCleanupTimer = () => {
|
// Wait for the outgoing connection to be ready before setting up piping
|
||||||
if (this.settings.maxConnectionLifetime) {
|
targetSocket.once('connect', () => {
|
||||||
if (connectionRecord.cleanupTimer) {
|
// Remove temporary data handler
|
||||||
clearTimeout(connectionRecord.cleanupTimer);
|
socket.removeListener('data', tempDataHandler);
|
||||||
|
|
||||||
|
// Flush all pending data to target
|
||||||
|
if (connectionRecord.pendingData.length > 0) {
|
||||||
|
const combinedData = Buffer.concat(connectionRecord.pendingData);
|
||||||
|
targetSocket.write(combinedData, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log(`Error writing pending data to target: ${err.message}`);
|
||||||
|
return initiateCleanupOnce('write_error');
|
||||||
}
|
}
|
||||||
connectionRecord.cleanupTimer = setTimeout(() => {
|
|
||||||
console.log(`Connection from ${remoteIP} exceeded max lifetime with inactivity (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
|
// Now set up piping for future data
|
||||||
cleanupOnce();
|
socket.pipe(targetSocket);
|
||||||
}, this.settings.maxConnectionLifetime);
|
targetSocket.pipe(socket);
|
||||||
}
|
|
||||||
};
|
console.log(
|
||||||
|
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
||||||
resetCleanupTimer();
|
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No pending data, so just set up piping
|
||||||
|
socket.pipe(targetSocket);
|
||||||
|
targetSocket.pipe(socket);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
||||||
|
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the buffer now that we've processed it
|
||||||
|
connectionRecord.pendingData = [];
|
||||||
|
|
||||||
|
// Set up activity tracking
|
||||||
socket.on('data', () => {
|
socket.on('data', () => {
|
||||||
incomingActive = true;
|
connectionRecord.lastActivity = Date.now();
|
||||||
if (incomingActive && outgoingActive) {
|
|
||||||
resetCleanupTimer();
|
|
||||||
incomingActive = false;
|
|
||||||
outgoingActive = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
targetSocket.on('data', () => {
|
targetSocket.on('data', () => {
|
||||||
outgoingActive = true;
|
connectionRecord.lastActivity = Date.now();
|
||||||
if (incomingActive && outgoingActive) {
|
|
||||||
resetCleanupTimer();
|
|
||||||
incomingActive = false;
|
|
||||||
outgoingActive = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add the renegotiation listener (we don't need setImmediate here anymore
|
||||||
|
// since we're already in the connect callback)
|
||||||
|
if (serverName) {
|
||||||
|
socket.on('data', (renegChunk: Buffer) => {
|
||||||
|
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
|
||||||
|
try {
|
||||||
|
// Try to extract SNI from potential renegotiation
|
||||||
|
const newSNI = extractSNI(renegChunk);
|
||||||
|
if (newSNI && newSNI !== connectionRecord.lockedDomain) {
|
||||||
|
console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
|
||||||
|
initiateCleanupOnce('sni_mismatch');
|
||||||
|
} else if (newSNI) {
|
||||||
|
console.log(`Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize a cleanup timer for max connection lifetime
|
||||||
|
if (this.settings.maxConnectionLifetime) {
|
||||||
|
connectionRecord.cleanupTimer = setTimeout(() => {
|
||||||
|
console.log(`Connection from ${remoteIP} exceeded max lifetime (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
|
||||||
|
initiateCleanupOnce('max_lifetime');
|
||||||
|
}, this.settings.maxConnectionLifetime);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -413,7 +541,15 @@ export class PortProxy {
|
|||||||
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
|
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
|
||||||
);
|
);
|
||||||
if (forcedDomain) {
|
if (forcedDomain) {
|
||||||
if (!isAllowed(remoteIP, forcedDomain.allowedIPs)) {
|
const effectiveAllowedIPs: string[] = [
|
||||||
|
...forcedDomain.allowedIPs,
|
||||||
|
...(this.settings.defaultAllowedIPs || [])
|
||||||
|
];
|
||||||
|
const effectiveBlockedIPs: string[] = [
|
||||||
|
...(forcedDomain.blockedIPs || []),
|
||||||
|
...(this.settings.defaultBlockedIPs || [])
|
||||||
|
];
|
||||||
|
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
||||||
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
|
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
|
||||||
socket.end();
|
socket.end();
|
||||||
return;
|
return;
|
||||||
@ -428,37 +564,25 @@ export class PortProxy {
|
|||||||
|
|
||||||
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
|
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
|
||||||
if (this.settings.sniEnabled) {
|
if (this.settings.sniEnabled) {
|
||||||
socket.setTimeout(5000, () => {
|
initialDataReceived = false;
|
||||||
console.log(`Initial data timeout for ${remoteIP}`);
|
|
||||||
socket.end();
|
|
||||||
cleanupOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.once('data', (chunk: Buffer) => {
|
socket.once('data', (chunk: Buffer) => {
|
||||||
socket.setTimeout(0);
|
if (initialTimeout) {
|
||||||
|
clearTimeout(initialTimeout);
|
||||||
|
initialTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
initialDataReceived = true;
|
initialDataReceived = true;
|
||||||
const serverName = extractSNI(chunk) || '';
|
const serverName = extractSNI(chunk) || '';
|
||||||
// Lock the connection to the negotiated SNI.
|
// Lock the connection to the negotiated SNI.
|
||||||
connectionRecord.lockedDomain = serverName;
|
connectionRecord.lockedDomain = serverName;
|
||||||
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
|
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
|
||||||
// Delay adding the renegotiation listener until the next tick,
|
|
||||||
// so the initial ClientHello is not reprocessed.
|
|
||||||
setImmediate(() => {
|
|
||||||
socket.on('data', (renegChunk: Buffer) => {
|
|
||||||
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
|
|
||||||
const newSNI = extractSNI(renegChunk);
|
|
||||||
if (newSNI && newSNI !== connectionRecord.lockedDomain) {
|
|
||||||
console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
|
|
||||||
cleanupOnce();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setupConnection(serverName, chunk);
|
setupConnection(serverName, chunk);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
initialDataReceived = true;
|
initialDataReceived = true;
|
||||||
if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
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`);
|
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
|
||||||
}
|
}
|
||||||
setupConnection('');
|
setupConnection('');
|
||||||
@ -496,21 +620,44 @@ export class PortProxy {
|
|||||||
|
|
||||||
// Log active connection count, longest running durations, and run parity checks every 10 seconds.
|
// Log active connection count, longest running durations, and run parity checks every 10 seconds.
|
||||||
this.connectionLogger = setInterval(() => {
|
this.connectionLogger = setInterval(() => {
|
||||||
|
if (this.isShuttingDown) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let maxIncoming = 0;
|
let maxIncoming = 0;
|
||||||
let maxOutgoing = 0;
|
let maxOutgoing = 0;
|
||||||
for (const record of this.connectionRecords) {
|
|
||||||
|
// Create a copy of the keys to avoid modification during iteration
|
||||||
|
const connectionIds = [...this.connectionRecords.keys()];
|
||||||
|
|
||||||
|
for (const id of connectionIds) {
|
||||||
|
const record = this.connectionRecords.get(id);
|
||||||
|
if (!record) continue;
|
||||||
|
|
||||||
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
|
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
|
||||||
if (record.outgoingStartTime) {
|
if (record.outgoingStartTime) {
|
||||||
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
|
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
|
||||||
}
|
}
|
||||||
// Parity check: if outgoing socket closed and incoming remains active for >1 minute, trigger special cleanup.
|
|
||||||
if (record.outgoingClosedTime && !record.incoming.destroyed && (now - record.outgoingClosedTime > 60000)) {
|
// Parity check: if outgoing socket closed and incoming remains active
|
||||||
|
if (record.outgoingClosedTime &&
|
||||||
|
!record.incoming.destroyed &&
|
||||||
|
!record.connectionClosed &&
|
||||||
|
(now - record.outgoingClosedTime > 30000)) {
|
||||||
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
||||||
console.log(`Parity check triggered: Incoming socket for ${remoteIP} has been active >1 minute after outgoing closed.`);
|
console.log(`Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing closed.`);
|
||||||
this.cleanupConnection(record, true);
|
this.cleanupConnection(record, 'parity_check');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inactivity check
|
||||||
|
const inactivityTime = now - record.lastActivity;
|
||||||
|
if (inactivityTime > 180000 && // 3 minutes
|
||||||
|
!record.connectionClosed) {
|
||||||
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
||||||
|
console.log(`Inactivity check: No activity on connection from ${remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
|
||||||
|
this.cleanupConnection(record, 'inactivity');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
|
`(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
|
||||||
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
|
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
|
||||||
@ -521,17 +668,69 @@ export class PortProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
// Close all servers.
|
console.log("PortProxy shutting down...");
|
||||||
const closePromises: Promise<void>[] = this.netServers.map(
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
// Stop accepting new connections
|
||||||
|
const closeServerPromises: Promise<void>[] = this.netServers.map(
|
||||||
server =>
|
server =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
server.close(() => resolve());
|
server.close(() => resolve());
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stop the connection logger
|
||||||
if (this.connectionLogger) {
|
if (this.connectionLogger) {
|
||||||
clearInterval(this.connectionLogger);
|
clearInterval(this.connectionLogger);
|
||||||
this.connectionLogger = null;
|
this.connectionLogger = null;
|
||||||
}
|
}
|
||||||
await Promise.all(closePromises);
|
|
||||||
|
// Wait for servers to close
|
||||||
|
await Promise.all(closeServerPromises);
|
||||||
|
console.log("All servers closed. Cleaning up active connections...");
|
||||||
|
|
||||||
|
// Clean up active connections
|
||||||
|
const connectionIds = [...this.connectionRecords.keys()];
|
||||||
|
console.log(`Cleaning up ${connectionIds.length} active connections...`);
|
||||||
|
|
||||||
|
for (const id of connectionIds) {
|
||||||
|
const record = this.connectionRecords.get(id);
|
||||||
|
if (record && !record.connectionClosed) {
|
||||||
|
this.cleanupConnection(record, 'shutdown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for graceful shutdown or timeout
|
||||||
|
const shutdownTimeout = this.settings.gracefulShutdownTimeout || 30000;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (this.connectionRecords.size === 0) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve(); // lets resolve here as early as we reach 0 remaining connections
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Force resolve after timeout
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
if (this.connectionRecords.size > 0) {
|
||||||
|
console.log(`Forcing shutdown with ${this.connectionRecords.size} connections still active`);
|
||||||
|
|
||||||
|
// Force destroy any remaining connections
|
||||||
|
for (const record of this.connectionRecords.values()) {
|
||||||
|
if (!record.incoming.destroyed) {
|
||||||
|
record.incoming.destroy();
|
||||||
|
}
|
||||||
|
if (record.outgoing && !record.outgoing.destroyed) {
|
||||||
|
record.outgoing.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.connectionRecords.clear();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, shutdownTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("PortProxy shutdown complete.");
|
||||||
}
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user