Compare commits
52 Commits
Author | SHA1 | Date | |
---|---|---|---|
6532e6f0e0 | |||
8791da83b4 | |||
9ad08edf79 | |||
c0de8c59a2 | |||
3748689c16 | |||
d0b3139fda | |||
fd4f731ada | |||
ced9b5b27b | |||
eb70a86304 | |||
131d9d326e | |||
12de96a7d5 | |||
296e1fcdc7 | |||
8459e4013c | |||
191c8ac0e6 | |||
3ab483d164 | |||
fcd80dc56b | |||
8ddffcd6e5 | |||
a5a7781c17 | |||
d647e77cdf | |||
9161336197 | |||
2e63d13dd4 | |||
af6ed735d5 | |||
7d38f29ef3 | |||
0df26d4367 | |||
f9a6e2d748 | |||
1cb6302750 | |||
f336f25535 | |||
5d6b707440 | |||
622ad2ff20 | |||
dd23efd28d | |||
0ddf68a919 | |||
ec08ca51f5 | |||
29688d1379 | |||
c83f6fa278 | |||
60333b0a59 | |||
1aa409907b | |||
adee6afc76 | |||
4a0792142f | |||
f1b810a4fa | |||
96b5877c5f | |||
6d627f67f7 | |||
9af968b8e7 | |||
b3ba0c21e8 | |||
ef707a5870 | |||
6ca14edb38 | |||
5a5686b6b9 | |||
2080f419cb | |||
659aae297b | |||
fcd0f61b5c | |||
7ee35a98e3 | |||
ea0f6d2270 | |||
621ad9e681 |
177
changelog.md
177
changelog.md
@ -1,5 +1,182 @@
|
||||
# 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
|
||||
|
||||
- Introduced a new field 'lockedDomain' in IConnectionRecord to store initial SNI.
|
||||
- Enhanced connection management by enforcing termination if rehandshake is detected with different SNI.
|
||||
|
||||
## 2025-02-27 - 3.17.1 - fix(PortProxy)
|
||||
Fix handling of SNI re-negotiation in PortProxy
|
||||
|
||||
- Removed connection locking to the initially negotiated SNI
|
||||
- Improved handling of SNI during renegotiation in PortProxy
|
||||
|
||||
## 2025-02-27 - 3.17.0 - feat(smartproxy)
|
||||
Enhance description clarity and improve SNI handling with domain locking.
|
||||
|
||||
- Improved package description in package.json, readme.md, and npmextra.json for better clarity and keyword optimization.
|
||||
- Enhanced SNI handling in PortProxy by adding domain locking and extra checks to terminate connections if a different SNI is detected post-handshake.
|
||||
- Refactored readme.md to better explain the usage and functionalities of the proxy features including SSL redirection, WebSocket handling, and dynamic routing.
|
||||
|
||||
## 2025-02-27 - 3.16.9 - fix(portproxy)
|
||||
Extend domain input validation to support string arrays in port proxy configurations.
|
||||
|
||||
- Modify IDomainConfig interface to allow domain specification as string array.
|
||||
- Update connection setup logic to handle multiple domain patterns.
|
||||
- Enhance domain rejection logging to include all domain patterns.
|
||||
|
||||
## 2025-02-27 - 3.16.8 - fix(PortProxy)
|
||||
Fix IP filtering for domain and global default allowed lists and improve port-based routing logic.
|
||||
|
||||
- Improved logic to prioritize domain-specific allowed IPs over global defaults.
|
||||
- Fixed port-based rules application to handle global port ranges more effectively.
|
||||
- Enhanced rejection handling for unauthorized IP addresses in both domain-specific and default global lists.
|
||||
|
||||
## 2025-02-27 - 3.16.7 - fix(PortProxy)
|
||||
Improved IP validation logic in PortProxy to ensure correct domain matching and fallback
|
||||
|
||||
- Refactored the setupConnection function inside PortProxy to enhance IP address validation.
|
||||
- Domain-specific allowed IP preference is applied before default list lookup.
|
||||
- Removed redundant condition checks to streamline connection rejection paths.
|
||||
|
||||
## 2025-02-27 - 3.16.6 - fix(PortProxy)
|
||||
Optimize connection cleanup logic in PortProxy by removing unnecessary delays.
|
||||
|
||||
- Removed multiple await plugins.smartdelay.delayFor(0) calls.
|
||||
- Improved performance by ensuring timely resource release during connection termination.
|
||||
|
||||
## 2025-02-27 - 3.16.5 - fix(PortProxy)
|
||||
Improved connection cleanup process with added asynchronous delays
|
||||
|
||||
- Connection cleanup now includes asynchronous delays for reliable order of operations.
|
||||
|
||||
## 2025-02-27 - 3.16.4 - fix(PortProxy)
|
||||
Fix and enhance port proxy handling
|
||||
|
||||
- Ensure that all created proxy servers are correctly checked for listening state.
|
||||
- Corrected the handling of ports and domain configurations within port proxy setups.
|
||||
- Expanded test coverage for handling multiple concurrent and chained proxy connections.
|
||||
|
||||
## 2025-02-27 - 3.16.3 - fix(PortProxy)
|
||||
Refactored PortProxy to support multiple listening ports and improved modularity.
|
||||
|
||||
- Updated PortProxy to allow multiple listening ports with flexible configuration.
|
||||
- Moved helper functions for IP and port range checks outside the class for cleaner code structure.
|
||||
|
||||
## 2025-02-27 - 3.16.2 - fix(PortProxy)
|
||||
Fix port-based routing logic in PortProxy
|
||||
|
||||
- Optimized the handling and checking of local ports in the global port range.
|
||||
- Fixed the logic for rejecting or accepting connections based on predefined port ranges.
|
||||
- Improved handling of the default and specific domain configurations during port-based connections.
|
||||
|
||||
## 2025-02-27 - 3.16.1 - fix(core)
|
||||
Updated minor version numbers in dependencies for patch release.
|
||||
|
||||
- No specific file changes detected.
|
||||
- Dependencies versioning adjusted for stability.
|
||||
|
||||
## 2025-02-27 - 3.16.0 - feat(PortProxy)
|
||||
Enhancements made to PortProxy settings and capabilities
|
||||
|
||||
|
@ -5,26 +5,26 @@
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartproxy",
|
||||
"description": "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.",
|
||||
"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.",
|
||||
"npmPackagename": "@push.rocks/smartproxy",
|
||||
"license": "MIT",
|
||||
"projectDomain": "push.rocks",
|
||||
"keywords": [
|
||||
"proxy",
|
||||
"network traffic",
|
||||
"high workload",
|
||||
"http",
|
||||
"https",
|
||||
"websocket",
|
||||
"network routing",
|
||||
"ssl redirect",
|
||||
"port mapping",
|
||||
"reverse proxy",
|
||||
"authentication",
|
||||
"network",
|
||||
"traffic management",
|
||||
"SSL",
|
||||
"TLS",
|
||||
"WebSocket",
|
||||
"port proxying",
|
||||
"dynamic routing",
|
||||
"sni",
|
||||
"port forwarding",
|
||||
"real-time applications"
|
||||
"authentication",
|
||||
"real-time applications",
|
||||
"high workload",
|
||||
"HTTPS",
|
||||
"reverse proxy",
|
||||
"server",
|
||||
"network security"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
30
package.json
30
package.json
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "3.16.0",
|
||||
"version": "3.23.0",
|
||||
"private": false,
|
||||
"description": "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.",
|
||||
"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",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
@ -53,20 +53,20 @@
|
||||
],
|
||||
"keywords": [
|
||||
"proxy",
|
||||
"network traffic",
|
||||
"high workload",
|
||||
"http",
|
||||
"https",
|
||||
"websocket",
|
||||
"network routing",
|
||||
"ssl redirect",
|
||||
"port mapping",
|
||||
"reverse proxy",
|
||||
"authentication",
|
||||
"network",
|
||||
"traffic management",
|
||||
"SSL",
|
||||
"TLS",
|
||||
"WebSocket",
|
||||
"port proxying",
|
||||
"dynamic routing",
|
||||
"sni",
|
||||
"port forwarding",
|
||||
"real-time applications"
|
||||
"authentication",
|
||||
"real-time applications",
|
||||
"high workload",
|
||||
"HTTPS",
|
||||
"reverse proxy",
|
||||
"server",
|
||||
"network security"
|
||||
],
|
||||
"homepage": "https://code.foss.global/push.rocks/smartproxy#readme",
|
||||
"repository": {
|
||||
|
484
readme.md
484
readme.md
@ -1,221 +1,389 @@
|
||||
# @push.rocks/smartproxy
|
||||
|
||||
A proxy for handling high workloads of proxying.
|
||||
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 and versatile package designed to handle complex and high-volume proxying tasks efficiently. It includes features such as SSL redirection, port proxying, WebSocket support, and customizable routing and authentication mechanisms. This guide will provide a detailed walkthrough of how to harness these capabilities effectively.
|
||||
|
||||
### Initial Setup
|
||||
|
||||
Before diving into specific features, let's start by configuring and setting up our basic proxy server:
|
||||
### Basic Reverse Proxy Setup
|
||||
|
||||
```typescript
|
||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
// Instantiate the NetworkProxy with desired options
|
||||
const myNetworkProxy = new NetworkProxy({ port: 443 });
|
||||
// Create a reverse proxy listening on port 443
|
||||
const proxy = new NetworkProxy({
|
||||
port: 443
|
||||
});
|
||||
|
||||
// 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'
|
||||
},
|
||||
// More 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
|
||||
await myNetworkProxy.start();
|
||||
|
||||
// Apply proxy configurations
|
||||
await myNetworkProxy.updateProxyConfigs(proxyConfigs);
|
||||
|
||||
// Optionally add default headers to 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
|
||||
|
||||
One essential capability of a robust proxy server is ensuring that all HTTP traffic is redirected to secure HTTPS endpoints. This can be effortlessly accomplished using the `SslRedirect` class within `smartproxy`. This class listens on port 80 (HTTP) and redirects all incoming requests to HTTPS:
|
||||
### HTTP to HTTPS Redirection
|
||||
|
||||
```typescript
|
||||
import { SslRedirect } from '@push.rocks/smartproxy';
|
||||
|
||||
// Instantiate the SslRedirect for listening on port 80
|
||||
const mySslRedirect = new SslRedirect(80);
|
||||
|
||||
// Start listening and redirect HTTP traffic to HTTPS
|
||||
await mySslRedirect.start();
|
||||
|
||||
// To stop redirection, you can 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();
|
||||
```
|
||||
|
||||
### Handling Complex Networking with Port Proxy
|
||||
|
||||
Port proxying allows redirection of traffic from one port to another. This capability is crucial when dealing with services that need dynamic port forwarding, or when adapting to infrastructure changes without downtime. Smartproxy's `PortProxy` class handles this efficiently:
|
||||
### TCP Port Forwarding with Domain-based Routing
|
||||
|
||||
```typescript
|
||||
import { PortProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
// Create a PortProxy to directly forward traffic from port 5000 to 3000
|
||||
const myPortProxy = new PortProxy(5000, 3000);
|
||||
|
||||
// Initiate the port proxy
|
||||
await myPortProxy.start();
|
||||
|
||||
// To stop the port proxy mechanism:
|
||||
await myPortProxy.stop();
|
||||
```
|
||||
|
||||
Additionally, smartproxy's port proxying can support intricate scenarios where different forwarding rules are configured based on domain names or allowed IPs:
|
||||
|
||||
```typescript
|
||||
import { PortProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const myComplexPortProxy = 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 }]
|
||||
}
|
||||
// Define more domain-specific rules if needed
|
||||
],
|
||||
sniEnabled: true, // if SNI (Server Name Indication) is desired
|
||||
defaultAllowedIPs: ['*']);
|
||||
maxConnectionLifetime: 3600000, // 1 hour in milliseconds
|
||||
preserveSourceIP: true
|
||||
});
|
||||
|
||||
// Start listening for complex routing requests
|
||||
await myComplexPortProxy.start();
|
||||
portProxy.start();
|
||||
```
|
||||
|
||||
### WebSocket Support and Load Handling
|
||||
|
||||
With the advent of real-time applications, efficient WebSocket handling in proxies is crucial. Smartproxy integrates WebSocket support seamlessly, enabling it to proxy WebSocket traffic while maintaining security and performance:
|
||||
|
||||
```typescript
|
||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const wsProxy = new NetworkProxy({ port: 443 });
|
||||
|
||||
// Assume reverse proxy configurations with WebSocket intentions
|
||||
const wsProxyConfigs = [
|
||||
{
|
||||
destinationIp: '127.0.0.1',
|
||||
destinationPort: '8080',
|
||||
hostName: 'socket.example.com',
|
||||
// Add further options such as keys for SSL if needed
|
||||
}
|
||||
];
|
||||
|
||||
// Start the network proxy with WebSocket capabilities
|
||||
await wsProxy.start();
|
||||
await wsProxy.updateProxyConfigs(wsProxyConfigs);
|
||||
|
||||
// Ensure WebSocket connections remain alive
|
||||
wsProxy.heartbeatInterval = setInterval(() => {
|
||||
// logic for keeping connections alive and healthy
|
||||
}, 60000); // Every 60 seconds
|
||||
|
||||
// Gracefully handle server or connection errors to maintain uptime
|
||||
wsProxy.httpsServer.on('error', (error) => console.log('Server Error:', error));
|
||||
```
|
||||
|
||||
### Comprehensive Routing and Advanced Features
|
||||
|
||||
Smartproxy supports dynamic and customizable request routing based on the incoming request's destination. This feature enables extensive use-case scenarios, from simple API endpoint redirection to elaborate B2B service integrations:
|
||||
|
||||
```typescript
|
||||
import { NetworkProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
const dynamicRoutingProxy = new NetworkProxy({ port: 8443 });
|
||||
dynamicRoutingProxy.router.setNewProxyConfigs([
|
||||
{
|
||||
destinationIp: '192.168.1.150',
|
||||
destinationPort: '80',
|
||||
hostName: 'dynamic.example.com',
|
||||
authentication: {
|
||||
type: 'Basic',
|
||||
user: 'admin',
|
||||
pass: 'password123'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await dynamicRoutingProxy.start();
|
||||
```
|
||||
|
||||
For those dealing with high volume or regulatory needs, the integration of tools like `iptables` allows broad control over network traffic:
|
||||
### IPTables Port Forwarding
|
||||
|
||||
```typescript
|
||||
import { IPTablesProxy } from '@push.rocks/smartproxy';
|
||||
|
||||
// Setting up iptables for advanced network 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 rules upon server shutdown
|
||||
toHost: 'localhost',
|
||||
preserveSourceIP: true,
|
||||
deleteOnExit: true // Automatically clean up rules on process exit
|
||||
});
|
||||
|
||||
// Begin routing with IPTables
|
||||
await ipTablesProxy.start();
|
||||
iptables.start();
|
||||
```
|
||||
|
||||
### Combining with HTTP and HTTPS Credentials
|
||||
|
||||
When undertaking proxy configurations, handling sensitive data like SSL certificates and keys securely is imperative:
|
||||
### Automatic HTTPS Certificate Management
|
||||
|
||||
```typescript
|
||||
import { loadDefaultCertificates } from '@push.rocks/smartproxy';
|
||||
import { Port80Handler } from '@push.rocks/smartproxy';
|
||||
|
||||
try {
|
||||
const { privateKey, publicKey } = loadDefaultCertificates(); // adjust path as needed
|
||||
console.log('Certificates loaded.');
|
||||
// Use these certificates in your SSL-based configurations
|
||||
} catch (error) {
|
||||
console.error('Cannot load 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
|
||||
|
||||
Given these powerful capabilities, rigorous testing of configurations and functionality using frameworks like `tap` can ensure high-quality and reliable proxy configurations. Smartproxy integrates with Typescript test setups:
|
||||
### 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('proxied request should return status 200', async () => {
|
||||
// Your test logic here
|
||||
});
|
||||
### 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 |
|
||||
|
||||
In summary, `@push.rocks/smartproxy` offers a plethora of solutions tailored to both common and sophisticated proxying needs. Whether you're seeking straightforward port forwarding, secure SSL redirection, WebSocket management, or robust network routing controls, smartproxy provides the right tools for efficient and effective proxy operations. Through its integration simplicity and versatile configurations, developers can ensure high performance and secure proxying across various environments and applications.
|
||||
### IPTablesProxy Settings
|
||||
|
||||
| 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
|
||||
|
||||
|
@ -8,85 +8,79 @@ 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);
|
||||
});
|
||||
|
||||
client.on('data', (chunk) => {
|
||||
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,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
targetIP: 'localhost',
|
||||
domainConfigs: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1']
|
||||
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();
|
||||
expect(portProxy.netServer.listening).toBeTrue();
|
||||
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
|
||||
const customHostProxy = new PortProxy({
|
||||
fromPort: PROXY_PORT + 1,
|
||||
toPort: TEST_SERVER_PORT,
|
||||
toHost: '127.0.0.1',
|
||||
domains: [],
|
||||
targetIP: '127.0.0.1',
|
||||
domainConfigs: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1']
|
||||
defaultAllowedIPs: ['127.0.0.1'],
|
||||
globalPortRanges: []
|
||||
});
|
||||
|
||||
await customHostProxy.start();
|
||||
@ -95,148 +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
|
||||
toHost: 'localhost', // default host
|
||||
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, // We'll test without SNI first since this is a TCP proxy test
|
||||
defaultAllowedIPs: ['127.0.0.1']
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1'],
|
||||
globalPortRanges: [{ from: forcedProxyPort, to: forcedProxyPort }]
|
||||
});
|
||||
|
||||
await domainProxy.start();
|
||||
|
||||
// Test default connection (should use default host)
|
||||
const response1 = await createTestClient(PROXY_PORT + 2, TEST_DATA);
|
||||
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
|
||||
|
||||
// Create another proxy with different default host
|
||||
const domainProxy2 = new PortProxy({
|
||||
fromPort: PROXY_PORT + 3,
|
||||
toPort: TEST_SERVER_PORT,
|
||||
toHost: '127.0.0.1',
|
||||
domains: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1']
|
||||
});
|
||||
|
||||
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.netServer.listening).toBeFalse();
|
||||
expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
// 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,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
targetIP: 'localhost',
|
||||
domainConfigs: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
|
||||
globalPortRanges: []
|
||||
});
|
||||
|
||||
const secondProxyDefault = new PortProxy({
|
||||
fromPort: PROXY_PORT + 5,
|
||||
toPort: TEST_SERVER_PORT,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
targetIP: 'localhost',
|
||||
domainConfigs: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||
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,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
targetIP: 'localhost',
|
||||
domainConfigs: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1'],
|
||||
preserveSourceIP: true
|
||||
preserveSourceIP: true,
|
||||
globalPortRanges: []
|
||||
});
|
||||
|
||||
const secondProxyPreserved = new PortProxy({
|
||||
fromPort: PROXY_PORT + 7,
|
||||
toPort: TEST_SERVER_PORT,
|
||||
toHost: 'localhost',
|
||||
domains: [],
|
||||
targetIP: 'localhost',
|
||||
domainConfigs: [],
|
||||
sniEnabled: false,
|
||||
defaultAllowedIPs: ['127.0.0.1'],
|
||||
preserveSourceIP: true
|
||||
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()));
|
||||
});
|
||||
@ -245,9 +242,9 @@ process.on('exit', () => {
|
||||
if (testServer) {
|
||||
testServer.close();
|
||||
}
|
||||
if (portProxy && portProxy.netServer) {
|
||||
if (portProxy && (portProxy as any).netServers) {
|
||||
portProxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '3.16.0',
|
||||
description: '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.'
|
||||
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.'
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
/** Domain configuration with per‐domain allowed port ranges */
|
||||
/** Domain configuration with per-domain allowed port ranges */
|
||||
export interface IDomainConfig {
|
||||
domain: string; // Glob pattern for domain
|
||||
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,20 +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;
|
||||
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 {
|
||||
netServer: plugins.net.Server;
|
||||
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>;
|
||||
@ -114,6 +159,7 @@ export class PortProxy {
|
||||
...settingsArg,
|
||||
targetIP: settingsArg.targetIP || 'localhost',
|
||||
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
|
||||
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
|
||||
};
|
||||
}
|
||||
|
||||
@ -121,73 +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() {
|
||||
// Helper to forcefully destroy sockets.
|
||||
const cleanUpSockets = (socketA: plugins.net.Socket, socketB?: plugins.net.Socket) => {
|
||||
if (!socketA.destroyed) socketA.destroy();
|
||||
if (socketB && !socketB.destroyed) socketB.destroy();
|
||||
};
|
||||
|
||||
// Normalize an IP to include both IPv4 and IPv6 representations.
|
||||
const normalizeIP = (ip: string): string[] => {
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
const ipv4 = ip.slice(7);
|
||||
return [ip, ipv4];
|
||||
// Define a unified connection handler for all listening ports.
|
||||
const connectionHandler = (socket: plugins.net.Socket) => {
|
||||
if (this.isShuttingDown) {
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
||||
return [ip, `::ffff:${ip}`];
|
||||
}
|
||||
return [ip];
|
||||
};
|
||||
|
||||
// Check if a given IP matches any of the glob patterns.
|
||||
const isAllowed = (ip: string, patterns: string[]): boolean => {
|
||||
const normalizedIPVariants = normalizeIP(ip);
|
||||
const expandedPatterns = patterns.flatMap(normalizeIP);
|
||||
return normalizedIPVariants.some(ipVariant =>
|
||||
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
|
||||
);
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Find a matching domain config based on SNI (fallback when port ranges aren’t used)
|
||||
const findMatchingDomain = (serverName: string): IDomainConfig | undefined =>
|
||||
this.settings.domains.find(config => plugins.minimatch(serverName, config.domain));
|
||||
|
||||
this.netServer = plugins.net.createServer((socket: plugins.net.Socket) => {
|
||||
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.
|
||||
// Local function for cleanupOnce
|
||||
const cleanupOnce = () => {
|
||||
if (!connectionRecord.connectionClosed) {
|
||||
connectionRecord.connectionClosed = true;
|
||||
if (connectionRecord.cleanupTimer) {
|
||||
clearTimeout(connectionRecord.cleanupTimer);
|
||||
}
|
||||
cleanUpSockets(connectionRecord.incoming, connectionRecord.outgoing || undefined);
|
||||
this.connectionRecords.delete(connectionRecord);
|
||||
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
|
||||
}
|
||||
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();
|
||||
@ -198,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) => {
|
||||
@ -221,7 +323,7 @@ export class PortProxy {
|
||||
outgoingTerminationReason = reason;
|
||||
this.incrementTerminationStat('outgoing', reason);
|
||||
}
|
||||
cleanupOnce();
|
||||
initiateCleanupOnce(reason);
|
||||
};
|
||||
|
||||
const handleClose = (side: 'incoming' | 'outgoing') => () => {
|
||||
@ -232,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);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -241,48 +345,71 @@ 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.
|
||||
*/
|
||||
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig) => {
|
||||
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
|
||||
const domainConfig = forcedDomain ? forcedDomain : (serverName ? findMatchingDomain(serverName) : undefined);
|
||||
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
|
||||
|
||||
if (!defaultAllowed && serverName && !forcedDomain) {
|
||||
if (!domainConfig) {
|
||||
return rejectIncomingConnection('rejected', `Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`);
|
||||
}
|
||||
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
|
||||
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`);
|
||||
}
|
||||
} else if (defaultAllowed && !serverName) {
|
||||
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`);
|
||||
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;
|
||||
}
|
||||
const targetHost = domainConfig?.targetIP || this.settings.targetIP!;
|
||||
|
||||
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
|
||||
const domainConfig = forcedDomain
|
||||
? forcedDomain
|
||||
: (serverName ? this.settings.domainConfigs.find(config =>
|
||||
config.domains.some(d => plugins.minimatch(serverName, d))
|
||||
) : undefined);
|
||||
|
||||
// IP validation is skipped if allowedIPs is empty
|
||||
if (domainConfig) {
|
||||
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 && 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 ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
|
||||
const connectionOptions: plugins.net.NetConnectOpts = {
|
||||
host: targetHost,
|
||||
port: this.settings.toPort,
|
||||
port: overridePort !== undefined ? overridePort : this.settings.toPort,
|
||||
};
|
||||
if (this.settings.preserveSourceIP) {
|
||||
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}:${this.settings.toPort}` +
|
||||
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${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'));
|
||||
@ -293,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}`);
|
||||
@ -301,58 +428,35 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
// --- PORT RANGE-BASED HANDLING ---
|
||||
// If global port ranges are defined, enforce port-based routing and ignore SNI.
|
||||
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
|
||||
if (!isPortInRanges(localPort, this.settings.globalPortRanges)) {
|
||||
console.log(`Connection from ${remoteIP} rejected: port ${localPort} is not in global allowed ranges.`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
// Only apply port-based rules if the incoming port is within one of the global port ranges.
|
||||
if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) {
|
||||
if (this.settings.forwardAllGlobalRanges) {
|
||||
// Forward connection to the global targetIP regardless of domain config.
|
||||
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
||||
console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
|
||||
socket.end();
|
||||
@ -360,80 +464,157 @@ 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 {
|
||||
// Find a matching domain config based on the incoming local port.
|
||||
const forcedDomain = this.settings.domains.find(
|
||||
// Attempt to find a matching forced domain config based on the local port.
|
||||
const forcedDomain = this.settings.domainConfigs.find(
|
||||
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
|
||||
);
|
||||
if (!forcedDomain) {
|
||||
console.log(`Connection from ${remoteIP} rejected: port ${localPort} not configured in any domain's portRanges.`);
|
||||
socket.destroy();
|
||||
if (forcedDomain) {
|
||||
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 ${forcedDomain.domains.join(', ')}.`);
|
||||
setupConnection('', undefined, forcedDomain, localPort);
|
||||
return;
|
||||
}
|
||||
// Check allowed IPs for the forced domain.
|
||||
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
|
||||
if (!defaultAllowed && !isAllowed(remoteIP, forcedDomain.allowedIPs)) {
|
||||
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domain} on port ${localPort}.`);
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domain}.`);
|
||||
// Proceed immediately using the forced domain; ignore SNI.
|
||||
setupConnection('', undefined, forcedDomain);
|
||||
return;
|
||||
// Fall through to SNI/default handling if no forced domain config is found.
|
||||
}
|
||||
}
|
||||
|
||||
// --- FALLBACK: SNI-BASED HANDLING (if no global port ranges are defined) ---
|
||||
// --- 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) {
|
||||
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('');
|
||||
}
|
||||
})
|
||||
.on('error', (err: Error) => {
|
||||
console.log(`Server Error: ${err.message}`);
|
||||
})
|
||||
.listen(this.settings.fromPort, () => {
|
||||
console.log(
|
||||
`PortProxy -> OK: Now listening on port ${this.settings.fromPort}` +
|
||||
`${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Log active connection count and longest running durations every 10 seconds.
|
||||
// --- SETUP LISTENERS ---
|
||||
// Determine which ports to listen on.
|
||||
const listeningPorts = new Set<number>();
|
||||
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
|
||||
// Listen on every port defined by the global ranges.
|
||||
for (const range of this.settings.globalPortRanges) {
|
||||
for (let port = range.from; port <= range.to; port++) {
|
||||
listeningPorts.add(port);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Create a server for each port.
|
||||
for (const port of listeningPorts) {
|
||||
const server = plugins.net
|
||||
.createServer(connectionHandler)
|
||||
.on('error', (err: Error) => {
|
||||
console.log(`Server Error on port ${port}: ${err.message}`);
|
||||
});
|
||||
server.listen(port, () => {
|
||||
console.log(`PortProxy -> OK: Now listening on port ${port}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`);
|
||||
});
|
||||
this.netServers.push(server);
|
||||
}
|
||||
|
||||
// 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,14 +625,69 @@ export class PortProxy {
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
const done = plugins.smartpromise.defer();
|
||||
this.netServer.close(() => {
|
||||
done.resolve();
|
||||
});
|
||||
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 done.promise;
|
||||
|
||||
// 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