Compare commits

...

28 Commits

Author SHA1 Message Date
6532e6f0e0 3.23.0 2025-03-03 03:18:49 +00:00
8791da83b4 feat(documentation): Updated documentation with architecture flow diagrams. 2025-03-03 03:18:49 +00:00
9ad08edf79 3.22.5 2025-03-03 03:05:50 +00:00
c0de8c59a2 fix(documentation): Refactored readme for clarity and consistency, fixed documentation typos 2025-03-03 03:05:49 +00:00
3748689c16 3.22.4 2025-03-03 02:16:48 +00:00
d0b3139fda fix(core): Addressed minor issues in the core modules to improve stability and performance. 2025-03-03 02:16:48 +00:00
fd4f731ada 3.22.3 2025-03-03 02:14:22 +00:00
ced9b5b27b fix(core): Improve connection management and error handling in PortProxy 2025-03-03 02:14:21 +00:00
eb70a86304 3.22.2 2025-03-03 02:03:24 +00:00
131d9d326e fix(portproxy): Refactored connection cleanup logic in PortProxy 2025-03-03 02:03:24 +00:00
12de96a7d5 3.22.1 2025-03-03 01:57:52 +00:00
296e1fcdc7 fix(PortProxy): Fix connection timeout and IP validation handling for PortProxy 2025-03-03 01:57:52 +00:00
8459e4013c 3.22.0 2025-03-03 01:50:30 +00:00
191c8ac0e6 feat(classes.portproxy): Enhanced PortProxy to support initial data timeout and improved IP handling 2025-03-03 01:50:30 +00:00
3ab483d164 3.21.0 2025-03-03 01:42:16 +00:00
fcd80dc56b feat(PortProxy): Enhancements to connection management in PortProxy 2025-03-03 01:42:16 +00:00
8ddffcd6e5 3.20.2 2025-03-01 20:31:50 +00:00
a5a7781c17 fix(PortProxy): Enhance connection cleanup handling in PortProxy 2025-03-01 20:31:50 +00:00
d647e77cdf 3.20.1 2025-03-01 17:32:31 +00:00
9161336197 fix(PortProxy): Improve IP allowance check for forced domains 2025-03-01 17:32:31 +00:00
2e63d13dd4 3.20.0 2025-03-01 17:19:27 +00:00
af6ed735d5 feat(PortProxy): Enhance PortProxy with advanced connection cleanup and logging 2025-03-01 17:19:27 +00:00
7d38f29ef3 3.19.0 2025-03-01 13:17:05 +00:00
0df26d4367 feat(PortProxy): Enhance PortProxy with default blocked IPs 2025-03-01 13:17:05 +00:00
f9a6e2d748 3.18.2 2025-02-27 21:25:03 +00:00
1cb6302750 fix(portproxy): Fixed typographical errors in comments within PortProxy class. 2025-02-27 21:25:03 +00:00
f336f25535 3.18.1 2025-02-27 21:19:34 +00:00
5d6b707440 fix(PortProxy): Refactor and enhance PortProxy test cases and handling 2025-02-27 21:19:34 +00:00
6 changed files with 849 additions and 386 deletions

View File

@ -1,5 +1,105 @@
# Changelog
## 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)
Enhance PortProxy with advanced connection cleanup and logging
- Introduced `cleanupConnection` method for improved connection management.
- Added logging for connection cleanup including special conditions.
- Implemented parity check to clean up connections when outgoing side closes but incoming remains active.
- Improved logging during interval checks for active connections and their durations.
## 2025-03-01 - 3.19.0 - feat(PortProxy)
Enhance PortProxy with default blocked IPs
- Introduced defaultBlockedIPs in IPortProxySettings to handle globally blocked IPs.
- Added logic for merging domain-specific and default allowed and blocked IPs for effective IP filtering.
- Refactored helper functions for IP and port range checks to improve modularity in PortProxy.
## 2025-02-27 - 3.18.2 - fix(portproxy)
Fixed typographical errors in comments within PortProxy class.
- Corrected typographical errors in comments within the PortProxy class.
## 2025-02-27 - 3.18.1 - fix(PortProxy)
Refactor and enhance PortProxy test cases and handling
- Refactored test cases in test/test.portproxy.ts for clarity and added coverage.
- Improved TCP server helper functions for better flexibility.
- Fixed issues with domain handling in PortProxy configuration.
- Introduced round-robin logic for multi-IP domains in PortProxy.
- Ensured proper cleanup and stopping of test servers in the test suite.
## 2025-02-27 - 3.18.0 - feat(PortProxy)
Add SNI-based renegotiation handling in PortProxy

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "3.18.0",
"version": "3.23.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",

491
readme.md
View File

@ -1,228 +1,389 @@
# @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
npm install @push.rocks/smartproxy --save
```mermaid
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
`@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.
### 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:
### Basic Reverse Proxy Setup
```typescript
import { NetworkProxy } from '@push.rocks/smartproxy';
// Create an instance of NetworkProxy with the desired configuration
const myNetworkProxy = new NetworkProxy({ port: 443 });
// Create a reverse proxy listening on 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 = [
{
destinationIp: '127.0.0.1',
destinationPort: '3000',
hostName: 'example.com',
privateKey: `-----BEGIN PRIVATE KEY-----
PRIVATE_KEY_CONTENT
-----END PRIVATE KEY-----`,
publicKey: `-----BEGIN CERTIFICATE-----
CERTIFICATE_CONTENT
-----END CERTIFICATE-----`,
destinationIp: '127.0.0.1',
destinationPort: 3000,
publicKey: 'your-cert-content',
privateKey: 'your-key-content'
},
// 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
await myNetworkProxy.start();
// Apply the configurations you defined earlier
await myNetworkProxy.updateProxyConfigs(proxyConfigs);
// Optionally, you can set default headers to be included in all responses
await myNetworkProxy.addDefaultHeaders({
'X-Powered-By': 'smartproxy',
});
// Start the proxy and update configurations
(async () => {
await proxy.start();
await proxy.updateProxyConfigs(proxyConfigs);
// Add default headers to all responses
await proxy.addDefaultHeaders({
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'
});
})();
```
### Configuring SSL 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:
### HTTP to HTTPS Redirection
```typescript
import { SslRedirect } from '@push.rocks/smartproxy';
// Create an SslRedirect instance to listen on port 80
const mySslRedirect = new SslRedirect(80);
// Start the redirect to enforce HTTPS
await mySslRedirect.start();
// To stop HTTP redirection, use the following command:
await mySslRedirect.stop();
// Create and start HTTP to HTTPS redirect service on port 80
const redirector = new SslRedirect(80);
redirector.start();
```
### Managing Port Proxying
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:
### TCP Port Forwarding with Domain-based Routing
```typescript
import { PortProxy } from '@push.rocks/smartproxy';
// Set up a PortProxy to forward traffic from port 5000 to 3000
const myPortProxy = new PortProxy(5000, 3000);
// Initiate the port proxy
await myPortProxy.start();
// To halt the port proxy, execute:
await myPortProxy.stop();
```
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: [
// Configure port proxy with domain-based routing
const portProxy = new PortProxy({
fromPort: 443,
toPort: 8443,
targetIP: 'localhost', // Default target host
sniEnabled: true, // Enable SNI inspection
globalPortRanges: [{ from: 443, to: 443 }],
defaultAllowedIPs: ['*'], // Allow all IPs by default
domainConfigs: [
{
domain: 'api.example.com',
allowedIPs: ['192.168.0.*', '127.0.0.1'],
targetIP: '192.168.1.100'
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 }]
}
// Additional domain rules can be added as needed
],
sniEnabled: true, // Server Name Indication (SNI) support
defaultAllowedIPs: ['*'],
maxConnectionLifetime: 3600000, // 1 hour in milliseconds
preserveSourceIP: true
});
// Activate the proxy with conditional rules
await advancedPortProxy.start();
portProxy.start();
```
### WebSocket Handling
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:
### IPTables Port Forwarding
```typescript
import { IPTablesProxy } from '@push.rocks/smartproxy';
// Set up IPTables for sophisticated network traffic management
const iptablesProxy = new IPTablesProxy({
fromPort: 8081,
// Configure IPTables to forward from port 80 to 8080
const iptables = new IPTablesProxy({
fromPort: 80,
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
await iptablesProxy.start();
iptables.start();
```
### Integrating SSL and HTTP/HTTPS Credentials
Handling sensitive data like SSL keys and certificates securely is crucial in proxy configurations:
### Automatic HTTPS Certificate Management
```typescript
import { loadDefaultCertificates } from '@push.rocks/smartproxy';
import { Port80Handler } from '@push.rocks/smartproxy';
try {
const { privateKey, publicKey } = loadDefaultCertificates(); // Adjust path if necessary
console.log('SSL certificates loaded successfully.');
// Use these credentials in your configurations
} catch (error) {
console.error('Error loading certificates:', error);
}
// Create an ACME handler for Let's Encrypt
const acmeHandler = new Port80Handler();
// Add domains to manage certificates for
acmeHandler.addDomain('example.com');
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
import { expect, tap } from '@push.rocks/tapbundle';
import { NetworkProxy } from '@push.rocks/smartproxy';
| Option | Description | Default |
|----------------|---------------------------------------------------|---------|
| `port` | Port to listen on for HTTPS connections | - |
tap.test('Check proxied request returns status 200', async () => {
// Testing logic
});
### PortProxy Settings
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

View File

@ -8,31 +8,30 @@ const TEST_SERVER_PORT = 4000;
const PROXY_PORT = 4001;
const TEST_DATA = 'Hello through port proxy!';
// Helper function to create a test TCP server
function createTestServer(port: number): Promise<net.Server> {
// Helper: Creates a test TCP server that listens on a given port and host.
function createTestServer(port: number, host: string = 'localhost'): Promise<net.Server> {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
socket.on('data', (data) => {
// Echo the received data back
// Echo the received data back with a prefix.
socket.write(`Echo: ${data.toString()}`);
});
socket.on('error', (error) => {
console.error('[Test Server] Socket error:', error);
console.error(`[Test Server] Socket error on ${host}:${port}:`, error);
});
});
server.listen(port, () => {
console.log(`[Test Server] Listening on port ${port}`);
server.listen(port, host, () => {
console.log(`[Test Server] Listening on ${host}:${port}`);
resolve(server);
});
});
}
// Helper function to create a test client connection
// Helper: Creates a test client connection.
function createTestClient(port: number, data: string): Promise<string> {
return new Promise((resolve, reject) => {
const client = new net.Socket();
let response = '';
client.connect(port, 'localhost', () => {
console.log('[Test Client] Connected to server');
client.write(data);
@ -41,47 +40,44 @@ function createTestClient(port: number, data: string): Promise<string> {
response += chunk.toString();
client.end();
});
client.on('end', () => {
resolve(response);
});
client.on('error', (error) => {
reject(error);
});
client.on('end', () => resolve(response));
client.on('error', (error) => reject(error));
});
}
// Setup test environment
// SETUP: Create a test server and a PortProxy instance.
tap.test('setup port proxy test environment', async () => {
testServer = await createTestServer(TEST_SERVER_PORT);
portProxy = new PortProxy({
fromPort: PROXY_PORT,
toPort: TEST_SERVER_PORT,
targetIP: 'localhost',
domains: [],
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
});
// Test that the proxy starts and its servers are listening.
tap.test('should start port proxy', async () => {
await portProxy.start();
// Since netServers is private, we cast to any to verify that all created servers are listening.
expect((portProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue();
});
// Test basic TCP forwarding.
tap.test('should forward TCP connections and data to localhost', async () => {
const response = await createTestClient(PROXY_PORT, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
});
// Test proxy with a custom target host.
tap.test('should forward TCP connections to custom host', async () => {
// Create a new proxy instance with a custom host (targetIP)
const customHostProxy = new PortProxy({
fromPort: PROXY_PORT + 1,
toPort: TEST_SERVER_PORT,
targetIP: '127.0.0.1',
domains: [],
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
@ -93,153 +89,151 @@ tap.test('should forward TCP connections to custom host', async () => {
await customHostProxy.stop();
});
tap.test('should forward connections based on domain-specific target IP', async () => {
// Create a second test server on a different port
const TEST_SERVER_PORT_2 = TEST_SERVER_PORT + 100;
const testServer2 = await createTestServer(TEST_SERVER_PORT_2);
// Test forced domain routing via port-range configuration.
// In this test, we want to forward to a different IP (using '127.0.0.2')
// while keeping the same port. We create a test server on '127.0.0.2'.
tap.test('should forward connections based on domain-specific target IP (forced domain via port-range)', async () => {
const forcedProxyPort = PROXY_PORT + 2;
// Create a test server listening on '127.0.0.2' at forcedProxyPort.
const testServer2 = await createTestServer(forcedProxyPort, '127.0.0.2');
// Create a proxy with domain-specific target IPs
const domainProxy = new PortProxy({
fromPort: PROXY_PORT + 2,
toPort: TEST_SERVER_PORT, // default port (for non-port-range handling)
targetIP: 'localhost', // default target IP
domains: [{
domain: 'domain1.test',
fromPort: forcedProxyPort,
toPort: TEST_SERVER_PORT, // default target port (unused for forced domain)
targetIP: 'localhost',
domainConfigs: [{
domains: ['forced.test'],
allowedIPs: ['127.0.0.1'],
targetIP: '127.0.0.1'
}, {
domain: 'domain2.test',
allowedIPs: ['127.0.0.1'],
targetIP: 'localhost'
targetIPs: ['127.0.0.2'], // Use a different IP than the default.
portRanges: [{ from: forcedProxyPort, to: forcedProxyPort }]
}],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
globalPortRanges: [{ from: forcedProxyPort, to: forcedProxyPort }]
});
await domainProxy.start();
// Test default connection (should use default targetIP)
const response1 = await createTestClient(PROXY_PORT + 2, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
// Create another proxy with a different default targetIP
const domainProxy2 = new PortProxy({
fromPort: PROXY_PORT + 3,
toPort: TEST_SERVER_PORT,
targetIP: '127.0.0.1',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
await domainProxy2.start();
const response2 = await createTestClient(PROXY_PORT + 3, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`);
// When connecting to forcedProxyPort, forced domain handling triggers,
// so the proxy will connect to '127.0.0.2' on the same port.
const response = await createTestClient(forcedProxyPort, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
await domainProxy.stop();
await domainProxy2.stop();
await new Promise<void>((resolve) => testServer2.close(() => resolve()));
});
// Test handling of multiple concurrent connections.
tap.test('should handle multiple concurrent connections', async () => {
const concurrentRequests = 5;
const requests = Array(concurrentRequests).fill(null).map((_, i) =>
const requests = Array(concurrentRequests).fill(null).map((_, i) =>
createTestClient(PROXY_PORT, `${TEST_DATA} ${i + 1}`)
);
const responses = await Promise.all(requests);
responses.forEach((response, i) => {
expect(response).toEqual(`Echo: ${TEST_DATA} ${i + 1}`);
});
});
// Test connection timeout handling.
tap.test('should handle connection timeouts', async () => {
const client = new net.Socket();
await new Promise<void>((resolve) => {
client.connect(PROXY_PORT, 'localhost', () => {
// Don't send any data, just wait for timeout
client.on('close', () => {
resolve();
});
// Do not send any data to trigger a timeout.
client.on('close', () => resolve());
});
});
});
// Test stopping the port proxy.
tap.test('should stop port proxy', async () => {
await portProxy.stop();
expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
});
// Cleanup chained proxies tests
// Test chained proxies with and without source IP preservation.
tap.test('should support optional source IP preservation in chained proxies', async () => {
// Test 1: Without IP preservation (default behavior)
// Chained proxies without IP preservation.
const firstProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 4,
toPort: PROXY_PORT + 5,
targetIP: 'localhost',
domains: [],
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
const secondProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 5,
toPort: TEST_SERVER_PORT,
targetIP: 'localhost',
domains: [],
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
await secondProxyDefault.start();
await firstProxyDefault.start();
// This should work because we explicitly allow both IPv4 and IPv6 formats
const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyDefault.stop();
await secondProxyDefault.stop();
// Test 2: With IP preservation
// Chained proxies with IP preservation.
const firstProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 6,
toPort: PROXY_PORT + 7,
targetIP: 'localhost',
domains: [],
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true,
globalPortRanges: []
});
const secondProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 7,
toPort: TEST_SERVER_PORT,
targetIP: 'localhost',
domains: [],
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true,
globalPortRanges: []
});
await secondProxyPreserved.start();
await firstProxyPreserved.start();
// This should work with just IPv4 because source IP is preserved
const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyPreserved.stop();
await secondProxyPreserved.stop();
});
// Test round-robin behavior for multiple target IPs in a domain config.
tap.test('should use round robin for multiple target IPs in domain config', async () => {
const domainConfig = {
domains: ['rr.test'],
allowedIPs: ['127.0.0.1'],
targetIPs: ['hostA', 'hostB']
} as any;
const proxyInstance = new PortProxy({
fromPort: 0,
toPort: 0,
targetIP: 'localhost',
domainConfigs: [domainConfig],
sniEnabled: false,
defaultAllowedIPs: [],
globalPortRanges: []
});
const firstTarget = (proxyInstance as any).getTargetIP(domainConfig);
const secondTarget = (proxyInstance as any).getTargetIP(domainConfig);
expect(firstTarget).toEqual('hostA');
expect(secondTarget).toEqual('hostB');
});
// CLEANUP: Tear down the test server.
tap.test('cleanup port proxy test environment', async () => {
await new Promise<void>((resolve) => testServer.close(() => resolve()));
});
@ -248,7 +242,6 @@ process.on('exit', () => {
if (testServer) {
testServer.close();
}
// Use a cast to access the private property for cleanup.
if (portProxy && (portProxy as any).netServers) {
portProxy.stop();
}

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.18.0',
version: '3.23.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.'
}

View File

@ -1,11 +1,12 @@
import * as plugins from './plugins.js';
/** Domain configuration with perdomain allowed port ranges */
/** Domain configuration with per-domain allowed port ranges */
export interface IDomainConfig {
domain: string | string[]; // Glob pattern or patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs
targetIP?: string; // Optional target IP for this domain
portRanges?: Array<{ from: number; to: number }>; // Optional domain-specific allowed port ranges
domains: string[]; // Glob patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs
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
}
/** Port proxy settings including global allowed port ranges */
@ -13,13 +14,15 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number;
toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domains: IDomainConfig[];
domainConfigs: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections
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
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
}
/**
@ -86,21 +89,62 @@ function extractSNI(buffer: Buffer): string | undefined {
}
interface IConnectionRecord {
id: string; // Unique connection identifier
incoming: plugins.net.Socket;
outgoing: plugins.net.Socket | null;
incomingStartTime: number;
outgoingStartTime?: number;
lockedDomain?: string; // New field to lock this connection to the initial SNI
connectionClosed: boolean;
cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity
outgoingClosedTime?: number;
lockedDomain?: string; // Used to lock this connection to the initial SNI
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
lastActivity: number; // Last activity timestamp for inactivity detection
}
// Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some(range => port >= range.from && port <= range.to);
};
// Helper: Check if a given IP matches any of the glob patterns
const isAllowed = (ip: string, patterns: string[]): boolean => {
const normalizeIP = (ip: string): string[] => {
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const normalizedIPVariants = normalizeIP(ip);
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some(ipVariant =>
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
);
};
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns
const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []): boolean => {
if (blocked.length > 0 && isAllowed(ip, blocked)) return false;
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 {
private netServers: plugins.net.Server[] = [];
settings: IPortProxySettings;
// Unified record tracking each connection pair.
private connectionRecords: Set<IConnectionRecord> = new Set();
private connectionRecords: Map<string, IConnectionRecord> = new Map();
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
// Map to track round robin indices for each domain config
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
private terminationStats: {
incoming: Record<string, number>;
@ -115,6 +159,7 @@ export class PortProxy {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
};
}
@ -122,39 +167,118 @@ export class PortProxy {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* Cleans up a connection record.
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
* @param record - The connection record to clean up
* @param reason - Optional reason for cleanup (for logging)
*/
private cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
if (!record.connectionClosed) {
record.connectionClosed = true;
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
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();
}
}
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();
}
}
// Remove the record from the tracking map
this.connectionRecords.delete(record.id);
const remoteIP = record.incoming.remoteAddress || 'unknown';
console.log(`Connection from ${remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
}
}
private updateActivity(record: IConnectionRecord): void {
record.lastActivity = Date.now();
}
private getTargetIP(domainConfig: IDomainConfig): string {
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP!;
}
public async start() {
// Define a unified connection handler for all listening ports.
const connectionHandler = (socket: plugins.net.Socket) => {
if (this.isShuttingDown) {
socket.end();
socket.destroy();
return;
}
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort; // The port on which this connection was accepted.
const connectionId = generateConnectionId();
const connectionRecord: IConnectionRecord = {
id: connectionId,
incoming: socket,
outgoing: null,
incomingStartTime: Date.now(),
connectionClosed: false,
lastActivity: Date.now(),
connectionClosed: false
};
this.connectionRecords.add(connectionRecord);
this.connectionRecords.set(connectionId, connectionRecord);
console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
let initialDataReceived = false;
let incomingTerminationReason: string | null = null;
let outgoingTerminationReason: string | null = null;
// Ensure cleanup happens only once for the entire connection record.
const cleanupOnce = async () => {
if (!connectionRecord.connectionClosed) {
connectionRecord.connectionClosed = true;
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
if (!socket.destroyed) socket.destroy();
if (connectionRecord.outgoing && !connectionRecord.outgoing.destroyed) connectionRecord.outgoing.destroy();
this.connectionRecords.delete(connectionRecord);
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
}
// Local function for cleanupOnce
const cleanupOnce = () => {
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) => {
console.log(logMessage);
socket.end();
@ -165,11 +289,22 @@ export class PortProxy {
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) => {
const errorMessage = initialDataReceived
? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`
: `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`;
console.log(errorMessage);
console.log(`Incoming socket error from ${remoteIP}: ${err.message}`);
});
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
@ -188,7 +323,7 @@ export class PortProxy {
outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason);
}
cleanupOnce();
initiateCleanupOnce(reason);
};
const handleClose = (side: 'incoming' | 'outgoing') => () => {
@ -199,8 +334,10 @@ export class PortProxy {
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
outgoingTerminationReason = 'normal';
this.incrementTerminationStat('outgoing', 'normal');
// Record the time when outgoing socket closed.
connectionRecord.outgoingClosedTime = Date.now();
}
cleanupOnce();
initiateCleanupOnce('closed_' + side);
};
/**
@ -208,32 +345,44 @@ export class PortProxy {
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
* @param initialChunk - Optional initial data chunk.
* @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) => {
// 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.
const domainConfig = forcedDomain
? forcedDomain
: (serverName ? this.settings.domains.find(config => {
if (typeof config.domain === 'string') {
return plugins.minimatch(serverName, config.domain);
} else {
return config.domain.some(d => plugins.minimatch(serverName, d));
}
}) : undefined);
: (serverName ? this.settings.domainConfigs.find(config =>
config.domains.some(d => plugins.minimatch(serverName, d))
) : undefined);
// If a matching domain config exists, check its allowedIPs.
// IP validation is skipped if allowedIPs is empty
if (domainConfig) {
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${Array.isArray(domainConfig.domain) ? domainConfig.domain.join(', ') : domainConfig.domain}`);
const effectiveAllowedIPs: string[] = [
...domainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || [])
];
const effectiveBlockedIPs: string[] = [
...(domainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || [])
];
// 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(', ')}`);
}
} else if (this.settings.defaultAllowedIPs) {
// Only check default allowed IPs if no domain config matched.
if (!isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
}
}
const targetHost = domainConfig?.targetIP || this.settings.targetIP!;
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: overridePort !== undefined ? overridePort : this.settings.toPort,
@ -242,23 +391,25 @@ export class PortProxy {
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
}
// Create the target socket and immediately set up data piping
const targetSocket = plugins.net.connect(connectionOptions);
connectionRecord.outgoing = targetSocket;
connectionRecord.outgoingStartTime = Date.now();
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain})` : ''}`
);
// Set up the pipe immediately to ensure data flows without delay
if (initialChunk) {
socket.unshift(initialChunk);
}
socket.setTimeout(120000);
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(', ')})` : ''}`
);
// Attach error and close handlers.
// Add appropriate handlers for connection management
socket.on('error', handleError('incoming'));
targetSocket.on('error', handleError('outgoing'));
socket.on('close', handleClose('incoming'));
@ -269,7 +420,7 @@ export class PortProxy {
incomingTerminationReason = 'timeout';
this.incrementTerminationStat('incoming', 'timeout');
}
cleanupOnce();
initiateCleanupOnce('timeout_incoming');
});
targetSocket.on('timeout', () => {
console.log(`Timeout on outgoing side from ${remoteIP}`);
@ -277,45 +428,28 @@ export class PortProxy {
outgoingTerminationReason = '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
socket.setTimeout(120000);
targetSocket.setTimeout(120000);
// Update activity for both sockets
socket.on('data', () => {
connectionRecord.lastActivity = Date.now();
});
targetSocket.on('data', () => {
connectionRecord.lastActivity = Date.now();
});
// Initialize a cleanup timer for max connection lifetime
if (this.settings.maxConnectionLifetime) {
let incomingActive = false;
let outgoingActive = false;
const resetCleanupTimer = () => {
if (this.settings.maxConnectionLifetime) {
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`Connection from ${remoteIP} exceeded max lifetime with inactivity (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
cleanupOnce();
}, this.settings.maxConnectionLifetime);
}
};
resetCleanupTimer();
socket.on('data', () => {
incomingActive = true;
if (incomingActive && outgoingActive) {
resetCleanupTimer();
incomingActive = false;
outgoingActive = false;
}
});
targetSocket.on('data', () => {
outgoingActive = true;
if (incomingActive && outgoingActive) {
resetCleanupTimer();
incomingActive = false;
outgoingActive = false;
}
});
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`Connection from ${remoteIP} exceeded max lifetime (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
initiateCleanupOnce('max_lifetime');
}, this.settings.maxConnectionLifetime);
}
};
@ -330,24 +464,33 @@ export class PortProxy {
}
console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
setupConnection('', undefined, {
domain: 'global',
domains: ['global'],
allowedIPs: this.settings.defaultAllowedIPs || [],
targetIP: this.settings.targetIP,
blockedIPs: this.settings.defaultBlockedIPs || [],
targetIPs: [this.settings.targetIP!],
portRanges: []
}, localPort);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.settings.domains.find(
const forcedDomain = this.settings.domainConfigs.find(
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
);
if (forcedDomain) {
if (!isAllowed(remoteIP, forcedDomain.allowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain} on port ${localPort}.`);
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}.`);
socket.end();
return;
}
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain}.`);
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
setupConnection('', undefined, forcedDomain, localPort);
return;
}
@ -357,37 +500,46 @@ export class PortProxy {
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) {
socket.setTimeout(5000, () => {
console.log(`Initial data timeout for ${remoteIP}`);
socket.end();
cleanupOnce();
});
initialDataReceived = false;
socket.once('data', (chunk: Buffer) => {
socket.setTimeout(0);
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
const serverName = extractSNI(chunk) || '';
// Lock the connection to the negotiated SNI.
connectionRecord.lockedDomain = 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();
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.`);
}
}
});
});
setupConnection(serverName, chunk);
});
} else {
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`);
}
setupConnection('');
@ -404,7 +556,7 @@ export class PortProxy {
listeningPorts.add(port);
}
}
// Also ensure the default fromPort is listened to if it isnt already in the ranges.
// Also ensure the default fromPort is listened to if it isn't already in the ranges.
listeningPorts.add(this.settings.fromPort);
} else {
listeningPorts.add(this.settings.fromPort);
@ -423,17 +575,46 @@ export class PortProxy {
this.netServers.push(server);
}
// Log active connection count and longest running durations every 10 seconds.
// Log active connection count, longest running durations, and run parity checks every 10 seconds.
this.connectionLogger = setInterval(() => {
if (this.isShuttingDown) return;
const now = Date.now();
let maxIncoming = 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);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
// 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';
console.log(`Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing closed.`);
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(
`(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
@ -444,41 +625,69 @@ export class PortProxy {
}
public async stop() {
// Close all servers.
const closePromises: Promise<void>[] = this.netServers.map(
console.log("PortProxy shutting down...");
this.isShuttingDown = true;
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
server =>
new Promise<void>((resolve) => {
server.close(() => resolve());
})
);
// Stop the connection logger
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
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.");
}
}
// Helper: Check if a port falls within any of the given port ranges.
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some(range => port >= range.from && port <= range.to);
};
// Helper: Check if a given IP matches any of the glob patterns.
const isAllowed = (ip: string, patterns: string[]): boolean => {
const normalizeIP = (ip: string): string[] => {
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const normalizedIPVariants = normalizeIP(ip);
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some(ipVariant =>
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
);
};
}