Compare commits
58 Commits
Author | SHA1 | Date | |
---|---|---|---|
266895ccc5 | |||
dc3d56771b | |||
38601a41bb | |||
a53e6f1019 | |||
3de35f3b2c | |||
b9210d891e | |||
133d5a47e0 | |||
f2f4e47893 | |||
e47436608f | |||
128f8203ac | |||
c7697eca84 | |||
71b5237cd4 | |||
2df2f0ceaf | |||
2b266ca779 | |||
c2547036fd | |||
a8131ece26 | |||
ad8c667dec | |||
942e0649c8 | |||
59625167b4 | |||
385d984727 | |||
a959c2ad0e | |||
88f5436c9a | |||
06101cd1b1 | |||
438d65107d | |||
233b26c308 | |||
ba787729e8 | |||
4854d7c38d | |||
e841bda003 | |||
477b930a37 | |||
935bd95723 | |||
0e33ea4eb5 | |||
6181065963 | |||
1a586dcbd7 | |||
ee03224561 | |||
483cbb3634 | |||
c77b31b72c | |||
8cb8fa1a52 | |||
8e5bb12edb | |||
9be9a426ad | |||
32d875aed9 | |||
4747462cff | |||
70f69ef1ea | |||
2be1c57dd7 | |||
58bd6b4a85 | |||
63e1cd48e8 | |||
5150ddc18e | |||
4bee483954 | |||
4328d4365f | |||
21e9d0fd0d | |||
6c0c65bb1a | |||
23f61eb60b | |||
a4ad6c59c1 | |||
e67eff0fcc | |||
e5db2e171c | |||
7389072841 | |||
9dd56a9362 | |||
1e7c45918e | |||
49b65508a5 |
186
changelog.md
186
changelog.md
@ -1,5 +1,191 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-02-24 - 3.10.5 - fix(portproxy)
|
||||||
|
Fix incorrect import path in test file
|
||||||
|
|
||||||
|
- Change import path from '../ts/smartproxy.portproxy.js' to '../ts/classes.portproxy.js' in test/test.portproxy.ts
|
||||||
|
|
||||||
|
## 2025-02-23 - 3.10.4 - fix(PortProxy)
|
||||||
|
Refactor connection tracking to utilize unified records in PortProxy
|
||||||
|
|
||||||
|
- Implemented a unified record system for tracking incoming and outgoing connections.
|
||||||
|
- Replaced individual connection tracking sets with a Set of IConnectionRecord.
|
||||||
|
- Improved logging of connection activities and statistics.
|
||||||
|
|
||||||
|
## 2025-02-23 - 3.10.3 - fix(PortProxy)
|
||||||
|
Refactor and optimize PortProxy for improved readability and maintainability
|
||||||
|
|
||||||
|
- Simplified and clarified inline comments.
|
||||||
|
- Optimized the extractSNI function for better readability.
|
||||||
|
- Streamlined the cleanup process for connections in PortProxy.
|
||||||
|
- Improved handling and logging of incoming and outgoing connections.
|
||||||
|
|
||||||
|
## 2025-02-23 - 3.10.2 - fix(PortProxy)
|
||||||
|
Fix connection handling to include timeouts for SNI-enabled connections.
|
||||||
|
|
||||||
|
- Added initial data timeout for SNI-enabled connections to improve connection handling.
|
||||||
|
- Cleared timeout once data is received to prevent premature socket closure.
|
||||||
|
|
||||||
|
## 2025-02-22 - 3.10.1 - fix(PortProxy)
|
||||||
|
Improve socket cleanup logic to prevent potential resource leaks
|
||||||
|
|
||||||
|
- Updated socket cleanup in PortProxy to ensure sockets are forcefully destroyed if not already destroyed.
|
||||||
|
|
||||||
|
## 2025-02-22 - 3.10.0 - feat(smartproxy.portproxy)
|
||||||
|
Enhance PortProxy with detailed connection statistics and termination tracking
|
||||||
|
|
||||||
|
- Added tracking of termination statistics for incoming and outgoing connections
|
||||||
|
- Enhanced logging to include detailed termination statistics
|
||||||
|
- Introduced helpers to update and log termination stats
|
||||||
|
- Retained detailed connection duration and active connection logging
|
||||||
|
|
||||||
|
## 2025-02-22 - 3.9.4 - fix(PortProxy)
|
||||||
|
Ensure proper cleanup on connection rejection in PortProxy
|
||||||
|
|
||||||
|
- Added cleanup calls after socket end in connection rejection scenarios within PortProxy
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.9.3 - fix(PortProxy)
|
||||||
|
Fix handling of optional outgoing socket in PortProxy
|
||||||
|
|
||||||
|
- Refactored the cleanUpSockets function to correctly handle cases where the outgoing socket may be undefined.
|
||||||
|
- Ensured correct handling of socket events with non-null assertions where applicable.
|
||||||
|
- Improved robustness in connection establishment and cleanup processes.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.9.2 - fix(PortProxy)
|
||||||
|
Improve timeout handling for port proxy connections
|
||||||
|
|
||||||
|
- Added console logging for both incoming and outgoing side timeouts in the PortProxy class.
|
||||||
|
- Updated the timeout event handlers to ensure proper cleanup of connections.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.9.1 - fix(dependencies)
|
||||||
|
Ensure correct ordering of dependencies and improve logging format.
|
||||||
|
|
||||||
|
- Reorder dependencies in package.json for better readability.
|
||||||
|
- Use pretty-ms for displaying time durations in logs.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.9.0 - feat(smartproxy.portproxy)
|
||||||
|
Add logging of connection durations to PortProxy
|
||||||
|
|
||||||
|
- Track start times for incoming and outgoing connections.
|
||||||
|
- Log duration of longest running incoming and outgoing connections every 10 seconds.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.8.1 - fix(plugins)
|
||||||
|
Simplified plugin import structure across codebase
|
||||||
|
|
||||||
|
- Consolidated plugin imports under a single 'plugins.ts' file.
|
||||||
|
- Replaced individual plugin imports in smartproxy files with the consolidated plugin imports.
|
||||||
|
- Fixed error handling for early socket errors in PortProxy setup.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.8.0 - feat(PortProxy)
|
||||||
|
Add active connection tracking and logging in PortProxy
|
||||||
|
|
||||||
|
- Implemented a feature to track active incoming connections in PortProxy.
|
||||||
|
- Active connections are now logged every 10 seconds for monitoring purposes.
|
||||||
|
- Refactored connection handling to ensure proper cleanup and logging.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.7.3 - fix(portproxy)
|
||||||
|
Fix handling of connections in PortProxy to improve stability and performance.
|
||||||
|
|
||||||
|
- Improved IP normalization and matching
|
||||||
|
- Better SNI extraction and handling for TLS
|
||||||
|
- Streamlined connection handling with robust error management
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.7.2 - fix(PortProxy)
|
||||||
|
Improve SNICallback and connection handling in PortProxy
|
||||||
|
|
||||||
|
- Fixed SNICallback to create minimal TLS context for SNI.
|
||||||
|
- Changed connection setup to use net.connect for raw passthrough.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.7.1 - fix(smartproxy.portproxy)
|
||||||
|
Optimize SNI handling by simplifying context creation
|
||||||
|
|
||||||
|
- Removed unnecessary SecureContext creation for SNI requests in PortProxy
|
||||||
|
- Improved handling of SNI passthrough by acknowledging requests without context creation
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.7.0 - feat(PortProxy)
|
||||||
|
Add optional source IP preservation support in PortProxy
|
||||||
|
|
||||||
|
- Added a feature to optionally preserve the client's source IP when proxying connections.
|
||||||
|
- Enhanced test cases to include scenarios for source IP preservation.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.6.0 - feat(PortProxy)
|
||||||
|
Add feature to preserve original client IP through chained proxies
|
||||||
|
|
||||||
|
- Added support to bind local address in PortProxy to preserve original client IP.
|
||||||
|
- Implemented test for chained proxies to ensure client IP is preserved.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.5.0 - feat(PortProxy)
|
||||||
|
Enhance PortProxy to support domain-specific target IPs
|
||||||
|
|
||||||
|
- Introduced support for domain-specific target IP configurations in PortProxy.
|
||||||
|
- Updated connection handling to prioritize domain-specific target IPs if provided.
|
||||||
|
- Added tests to verify forwarding based on domain-specific target IPs.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.4.4 - fix(PortProxy)
|
||||||
|
Fixed handling of SNI domain connections and IP allowance checks
|
||||||
|
|
||||||
|
- Improved logic for handling SNI domain checks, ensuring IPs are correctly verified.
|
||||||
|
- Fixed issue where default allowed IPs were not being checked correctly for non-SNI connections.
|
||||||
|
- Revised the SNICallback behavior to handle connections more gracefully when domain configurations are unavailable.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.4.3 - fix(PortProxy)
|
||||||
|
Fixed indentation issue and ensured proper cleanup of sockets in PortProxy
|
||||||
|
|
||||||
|
- Fixed inconsistent indentation in IP allowance check.
|
||||||
|
- Ensured proper cleanup of sockets on connection end in PortProxy.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.4.2 - fix(smartproxy)
|
||||||
|
Enhance SSL/TLS handling with SNI and error logging
|
||||||
|
|
||||||
|
- Improved handling for SNI-enabled and non-SNI connections
|
||||||
|
- Added detailed logging for connection establishment and rejections
|
||||||
|
- Introduced error logging for TLS client errors and server errors
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.4.1 - fix(PortProxy)
|
||||||
|
Normalize IP addresses for port proxy to handle IPv4-mapped IPv6 addresses.
|
||||||
|
|
||||||
|
- Improved IP normalization logic in PortProxy to support IPv4-mapped IPv6 addresses.
|
||||||
|
- Updated isAllowed function to expand patterns for better matching accuracy.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.4.0 - feat(PortProxy)
|
||||||
|
Enhanced PortProxy with custom target host and improved testing
|
||||||
|
|
||||||
|
- PortProxy constructor now accepts 'fromPort', 'toPort', and optional 'toHost' directly from settings
|
||||||
|
- Refactored test cases to cover forwarding to the custom host
|
||||||
|
- Added support to handle multiple concurrent connections
|
||||||
|
- Refactored internal connection handling logic to utilize default configurations
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.3.1 - fix(PortProxy)
|
||||||
|
fixed import usage of net and tls libraries for PortProxy
|
||||||
|
|
||||||
|
- Corrected the use of plugins for importing 'tls' and 'net' libraries in the PortProxy module.
|
||||||
|
- Updated the constructor of PortProxy to accept combined tls options with ProxySettings.
|
||||||
|
|
||||||
|
## 2025-02-21 - 3.3.0 - feat(PortProxy)
|
||||||
|
Enhanced PortProxy with domain and IP filtering, SNI support, and minimatch integration
|
||||||
|
|
||||||
|
- Added new ProxySettings interface to configure domain patterns, SNI, and default allowed IPs.
|
||||||
|
- Integrated minimatch to filter allowed IPs and domains.
|
||||||
|
- Enabled SNI support for PortProxy connections.
|
||||||
|
- Updated port proxy test to accommodate new settings.
|
||||||
|
|
||||||
|
## 2025-02-04 - 3.2.0 - feat(testing)
|
||||||
|
Added a comprehensive test suite for the PortProxy class
|
||||||
|
|
||||||
|
- Set up a test environment for PortProxy using net.Server.
|
||||||
|
- Test coverage includes starting and stopping the proxy, handling TCP connections, concurrent connections, and timeouts.
|
||||||
|
- Ensures proper resource cleanup after tests.
|
||||||
|
|
||||||
|
## 2025-02-04 - 3.1.4 - fix(core)
|
||||||
|
No uncommitted changes. Preparing for potential minor improvements or bug fixes.
|
||||||
|
|
||||||
|
|
||||||
|
## 2025-02-04 - 3.1.3 - fix(networkproxy)
|
||||||
|
Refactor and improve WebSocket handling and request processing
|
||||||
|
|
||||||
|
- Improved error handling in WebSocket connection and request processing.
|
||||||
|
- Refactored the WebSocket handling in NetworkProxy to use a unified error logging mechanism.
|
||||||
|
|
||||||
## 2025-02-04 - 3.1.2 - fix(core)
|
## 2025-02-04 - 3.1.2 - fix(core)
|
||||||
Refactor certificate handling across the project
|
Refactor certificate handling across the project
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "3.1.2",
|
"version": "3.10.5",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "a proxy for handling high workloads of proxying",
|
"description": "a proxy for handling high workloads of proxying",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@ -29,7 +29,10 @@
|
|||||||
"@push.rocks/smartrequest": "^2.0.23",
|
"@push.rocks/smartrequest": "^2.0.23",
|
||||||
"@push.rocks/smartstring": "^4.0.15",
|
"@push.rocks/smartstring": "^4.0.15",
|
||||||
"@tsclass/tsclass": "^4.4.0",
|
"@tsclass/tsclass": "^4.4.0",
|
||||||
|
"@types/minimatch": "^5.1.2",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
|
"minimatch": "^9.0.3",
|
||||||
|
"pretty-ms": "^9.2.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -26,9 +26,18 @@ importers:
|
|||||||
'@tsclass/tsclass':
|
'@tsclass/tsclass':
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
|
'@types/minimatch':
|
||||||
|
specifier: ^5.1.2
|
||||||
|
version: 5.1.2
|
||||||
'@types/ws':
|
'@types/ws':
|
||||||
specifier: ^8.5.14
|
specifier: ^8.5.14
|
||||||
version: 8.5.14
|
version: 8.5.14
|
||||||
|
minimatch:
|
||||||
|
specifier: ^9.0.3
|
||||||
|
version: 9.0.5
|
||||||
|
pretty-ms:
|
||||||
|
specifier: ^9.2.0
|
||||||
|
version: 9.2.0
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.18.0
|
specifier: ^8.18.0
|
||||||
version: 8.18.0
|
version: 8.18.0
|
||||||
|
253
test/test.portproxy.ts
Normal file
253
test/test.portproxy.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import { expect, tap } from '@push.rocks/tapbundle';
|
||||||
|
import * as net from 'net';
|
||||||
|
import { PortProxy } from '../ts/classes.portproxy.js';
|
||||||
|
|
||||||
|
let testServer: net.Server;
|
||||||
|
let portProxy: PortProxy;
|
||||||
|
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> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
// Echo the received data back
|
||||||
|
socket.write(`Echo: ${data.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.error('[Test Server] Socket error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`[Test Server] Listening on port ${port}`);
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup test environment
|
||||||
|
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: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should start port proxy', async () => {
|
||||||
|
await portProxy.start();
|
||||||
|
expect(portProxy.netServer.listening).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1']
|
||||||
|
});
|
||||||
|
|
||||||
|
await customHostProxy.start();
|
||||||
|
const response = await createTestClient(PROXY_PORT + 1, TEST_DATA);
|
||||||
|
expect(response).toEqual(`Echo: ${TEST_DATA}`);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
allowedIPs: ['127.0.0.1'],
|
||||||
|
targetIP: '127.0.0.1'
|
||||||
|
}, {
|
||||||
|
domain: 'domain2.test',
|
||||||
|
allowedIPs: ['127.0.0.1'],
|
||||||
|
targetIP: 'localhost'
|
||||||
|
}],
|
||||||
|
sniEnabled: false, // We'll test without SNI first since this is a TCP proxy test
|
||||||
|
defaultAllowedIPs: ['127.0.0.1']
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
await domainProxy.stop();
|
||||||
|
await domainProxy2.stop();
|
||||||
|
await new Promise<void>((resolve) => testServer2.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle multiple concurrent connections', async () => {
|
||||||
|
const concurrentRequests = 5;
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop port proxy', async () => {
|
||||||
|
await portProxy.stop();
|
||||||
|
expect(portProxy.netServer.listening).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
tap.test('should support optional source IP preservation in chained proxies', async () => {
|
||||||
|
// Test 1: Without IP preservation (default behavior)
|
||||||
|
const firstProxyDefault = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 4,
|
||||||
|
toPort: PROXY_PORT + 5,
|
||||||
|
toHost: 'localhost',
|
||||||
|
domains: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondProxyDefault = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 5,
|
||||||
|
toPort: TEST_SERVER_PORT,
|
||||||
|
toHost: 'localhost',
|
||||||
|
domains: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
const firstProxyPreserved = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 6,
|
||||||
|
toPort: PROXY_PORT + 7,
|
||||||
|
toHost: 'localhost',
|
||||||
|
domains: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1'],
|
||||||
|
preserveSourceIP: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondProxyPreserved = new PortProxy({
|
||||||
|
fromPort: PROXY_PORT + 7,
|
||||||
|
toPort: TEST_SERVER_PORT,
|
||||||
|
toHost: 'localhost',
|
||||||
|
domains: [],
|
||||||
|
sniEnabled: false,
|
||||||
|
defaultAllowedIPs: ['127.0.0.1'],
|
||||||
|
preserveSourceIP: true
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup port proxy test environment', async () => {
|
||||||
|
await new Promise<void>((resolve) => testServer.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('exit', () => {
|
||||||
|
if (testServer) {
|
||||||
|
testServer.close();
|
||||||
|
}
|
||||||
|
if (portProxy && portProxy.netServer) {
|
||||||
|
portProxy.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
133
test/test.ts
133
test/test.ts
@ -14,19 +14,34 @@ let testCertificates: { privateKey: string; publicKey: string };
|
|||||||
async function makeHttpsRequest(
|
async function makeHttpsRequest(
|
||||||
options: https.RequestOptions,
|
options: https.RequestOptions,
|
||||||
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> {
|
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||||
|
console.log('[TEST] Making HTTPS request:', {
|
||||||
|
hostname: options.hostname,
|
||||||
|
port: options.port,
|
||||||
|
path: options.path,
|
||||||
|
method: options.method,
|
||||||
|
headers: options.headers,
|
||||||
|
});
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
|
console.log('[TEST] Received HTTPS response:', {
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
headers: res.headers,
|
||||||
|
});
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', (chunk) => (data += chunk));
|
res.on('data', (chunk) => (data += chunk));
|
||||||
res.on('end', () =>
|
res.on('end', () => {
|
||||||
|
console.log('[TEST] Response completed:', { data });
|
||||||
resolve({
|
resolve({
|
||||||
statusCode: res.statusCode!,
|
statusCode: res.statusCode!,
|
||||||
headers: res.headers,
|
headers: res.headers,
|
||||||
body: data,
|
body: data,
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.error('[TEST] Request error:', error);
|
||||||
|
reject(error);
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -37,12 +52,13 @@ tap.test('setup test environment', async () => {
|
|||||||
console.log('[TEST] Loading and validating certificates');
|
console.log('[TEST] Loading and validating certificates');
|
||||||
testCertificates = loadTestCertificates();
|
testCertificates = loadTestCertificates();
|
||||||
console.log('[TEST] Certificates loaded and validated');
|
console.log('[TEST] Certificates loaded and validated');
|
||||||
|
|
||||||
// Create a test HTTP server
|
// Create a test HTTP server
|
||||||
testServer = http.createServer((req, res) => {
|
testServer = http.createServer((req, res) => {
|
||||||
console.log('[TEST SERVER] Received HTTP request:', {
|
console.log('[TEST SERVER] Received HTTP request:', {
|
||||||
url: req.url,
|
url: req.url,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: req.headers
|
headers: req.headers,
|
||||||
});
|
});
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
res.end('Hello from test server!');
|
res.end('Hello from test server!');
|
||||||
@ -59,8 +75,8 @@ tap.test('setup test environment', async () => {
|
|||||||
connection: request.headers.connection,
|
connection: request.headers.connection,
|
||||||
'sec-websocket-key': request.headers['sec-websocket-key'],
|
'sec-websocket-key': request.headers['sec-websocket-key'],
|
||||||
'sec-websocket-version': request.headers['sec-websocket-version'],
|
'sec-websocket-version': request.headers['sec-websocket-version'],
|
||||||
'sec-websocket-protocol': request.headers['sec-websocket-protocol']
|
'sec-websocket-protocol': request.headers['sec-websocket-protocol'],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
|
if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
|
||||||
@ -76,13 +92,13 @@ tap.test('setup test environment', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a WebSocket server
|
// Create a WebSocket server (for the test HTTP server)
|
||||||
console.log('[TEST SERVER] Creating WebSocket server');
|
console.log('[TEST SERVER] Creating WebSocket server');
|
||||||
wsServer = new WebSocketServer({
|
wsServer = new WebSocketServer({
|
||||||
noServer: true,
|
noServer: true,
|
||||||
perMessageDeflate: false,
|
perMessageDeflate: false,
|
||||||
clientTracking: true,
|
clientTracking: true,
|
||||||
handleProtocols: () => 'echo-protocol'
|
handleProtocols: () => 'echo-protocol',
|
||||||
});
|
});
|
||||||
|
|
||||||
wsServer.on('connection', (ws, request) => {
|
wsServer.on('connection', (ws, request) => {
|
||||||
@ -94,8 +110,8 @@ tap.test('setup test environment', async () => {
|
|||||||
connection: request.headers.connection,
|
connection: request.headers.connection,
|
||||||
'sec-websocket-key': request.headers['sec-websocket-key'],
|
'sec-websocket-key': request.headers['sec-websocket-key'],
|
||||||
'sec-websocket-version': request.headers['sec-websocket-version'],
|
'sec-websocket-version': request.headers['sec-websocket-version'],
|
||||||
'sec-websocket-protocol': request.headers['sec-websocket-protocol']
|
'sec-websocket-protocol': request.headers['sec-websocket-protocol'],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up connection timeout
|
// Set up connection timeout
|
||||||
@ -132,7 +148,7 @@ tap.test('setup test environment', async () => {
|
|||||||
console.log('[TEST SERVER] WebSocket connection closed:', {
|
console.log('[TEST SERVER] WebSocket connection closed:', {
|
||||||
code,
|
code,
|
||||||
reason: reason.toString(),
|
reason: reason.toString(),
|
||||||
wasClean: code === 1000 || code === 1001
|
wasClean: code === 1000 || code === 1001,
|
||||||
});
|
});
|
||||||
clearConnectionTimeout();
|
clearConnectionTimeout();
|
||||||
});
|
});
|
||||||
@ -175,30 +191,42 @@ tap.test('should create proxy instance', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should start the proxy server', async () => {
|
tap.test('should start the proxy server', async () => {
|
||||||
|
// Ensure any previous server is closed
|
||||||
|
if (testProxy && testProxy.httpsServer) {
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
testProxy.httpsServer.close(() => resolve())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[TEST] Starting the proxy server');
|
console.log('[TEST] Starting the proxy server');
|
||||||
await testProxy.start();
|
await testProxy.start();
|
||||||
console.log('[TEST] Proxy server started');
|
console.log('[TEST] Proxy server started');
|
||||||
|
|
||||||
// Configure proxy with test certificates
|
// Configure proxy with test certificates
|
||||||
testProxy.updateProxyConfigs([
|
// Awaiting the update ensures that the SNI context is added before any requests come in.
|
||||||
|
await testProxy.updateProxyConfigs([
|
||||||
{
|
{
|
||||||
destinationIp: '127.0.0.1',
|
destinationIp: '127.0.0.1',
|
||||||
destinationPort: '3000',
|
destinationPort: '3000',
|
||||||
hostName: 'push.rocks',
|
hostName: 'push.rocks',
|
||||||
publicKey: testCertificates.publicKey,
|
publicKey: testCertificates.publicKey,
|
||||||
privateKey: testCertificates.privateKey,
|
privateKey: testCertificates.privateKey,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('[TEST] Proxy configuration updated');
|
console.log('[TEST] Proxy configuration updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should route HTTPS requests based on host header', async () => {
|
tap.test('should route HTTPS requests based on host header', async () => {
|
||||||
|
// IMPORTANT: Connect to localhost (where the proxy is listening) but use the Host header "push.rocks"
|
||||||
const response = await makeHttpsRequest({
|
const response = await makeHttpsRequest({
|
||||||
hostname: 'push.rocks',
|
hostname: 'localhost', // changed from 'push.rocks' to 'localhost'
|
||||||
port: 3001,
|
port: 3001,
|
||||||
path: '/',
|
path: '/',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
host: 'push.rocks', // virtual host for routing
|
||||||
|
},
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -207,15 +235,21 @@ tap.test('should route HTTPS requests based on host header', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should handle unknown host headers', async () => {
|
tap.test('should handle unknown host headers', async () => {
|
||||||
|
// Connect to localhost but use an unknown host header.
|
||||||
const response = await makeHttpsRequest({
|
const response = await makeHttpsRequest({
|
||||||
hostname: 'unknown.host',
|
hostname: 'localhost', // connecting to localhost
|
||||||
port: 3001,
|
port: 3001,
|
||||||
path: '/',
|
path: '/',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
host: 'unknown.host', // this should not match any proxy config
|
||||||
|
},
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
}).catch((e) => e);
|
});
|
||||||
|
|
||||||
expect(response instanceof Error).toEqual(true);
|
// Expect a 404 response with the appropriate error message.
|
||||||
|
expect(response.statusCode).toEqual(404);
|
||||||
|
expect(response.body).toEqual('This route is not available on this server.');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should support WebSocket connections', async () => {
|
tap.test('should support WebSocket connections', async () => {
|
||||||
@ -224,39 +258,38 @@ tap.test('should support WebSocket connections', async () => {
|
|||||||
console.log('[TEST] Proxy server port:', 3001);
|
console.log('[TEST] Proxy server port:', 3001);
|
||||||
console.log('\n[TEST] Starting WebSocket test');
|
console.log('\n[TEST] Starting WebSocket test');
|
||||||
|
|
||||||
// First configure the proxy with test certificates
|
// Reconfigure proxy with test certificates if necessary
|
||||||
console.log('[TEST] Configuring proxy with test certificates');
|
await testProxy.updateProxyConfigs([
|
||||||
testProxy.updateProxyConfigs([
|
|
||||||
{
|
{
|
||||||
destinationIp: '127.0.0.1',
|
destinationIp: '127.0.0.1',
|
||||||
destinationPort: '3000',
|
destinationPort: '3000',
|
||||||
hostName: 'push.rocks',
|
hostName: 'push.rocks',
|
||||||
publicKey: testCertificates.publicKey,
|
publicKey: testCertificates.publicKey,
|
||||||
privateKey: testCertificates.privateKey,
|
privateKey: testCertificates.privateKey,
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
console.log('[TEST] Creating WebSocket client');
|
console.log('[TEST] Creating WebSocket client');
|
||||||
|
|
||||||
// Create WebSocket client with SSL/TLS options
|
// IMPORTANT: Connect to localhost but specify the SNI servername and Host header as "push.rocks"
|
||||||
const wsUrl = 'wss://push.rocks:3001';
|
const wsUrl = 'wss://localhost:3001'; // changed from 'wss://push.rocks:3001'
|
||||||
console.log('[TEST] Creating WebSocket connection to:', wsUrl);
|
console.log('[TEST] Creating WebSocket connection to:', wsUrl);
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl, {
|
const ws = new WebSocket(wsUrl, {
|
||||||
rejectUnauthorized: false, // Accept self-signed certificates
|
rejectUnauthorized: false, // Accept self-signed certificates
|
||||||
handshakeTimeout: 5000,
|
handshakeTimeout: 5000,
|
||||||
perMessageDeflate: false,
|
perMessageDeflate: false,
|
||||||
headers: {
|
headers: {
|
||||||
'Host': 'push.rocks',
|
Host: 'push.rocks', // required for SNI and routing on the proxy
|
||||||
'Connection': 'Upgrade',
|
Connection: 'Upgrade',
|
||||||
'Upgrade': 'websocket',
|
Upgrade: 'websocket',
|
||||||
'Sec-WebSocket-Version': '13'
|
'Sec-WebSocket-Version': '13',
|
||||||
},
|
},
|
||||||
protocol: 'echo-protocol',
|
protocol: 'echo-protocol',
|
||||||
agent: new https.Agent({
|
agent: new https.Agent({
|
||||||
rejectUnauthorized: false // Also needed for the underlying HTTPS connection
|
rejectUnauthorized: false, // Also needed for the underlying HTTPS connection
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST] WebSocket client created');
|
console.log('[TEST] WebSocket client created');
|
||||||
@ -286,7 +319,7 @@ tap.test('should support WebSocket connections', async () => {
|
|||||||
ws.on('upgrade', (response) => {
|
ws.on('upgrade', (response) => {
|
||||||
console.log('[TEST] WebSocket upgrade response received:', {
|
console.log('[TEST] WebSocket upgrade response received:', {
|
||||||
headers: response.headers,
|
headers: response.headers,
|
||||||
statusCode: response.statusCode
|
statusCode: response.statusCode,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -304,7 +337,10 @@ tap.test('should support WebSocket connections', async () => {
|
|||||||
|
|
||||||
ws.on('message', (message) => {
|
ws.on('message', (message) => {
|
||||||
console.log('[TEST] Received message:', message.toString());
|
console.log('[TEST] Received message:', message.toString());
|
||||||
if (message.toString() === 'Hello WebSocket') {
|
if (
|
||||||
|
message.toString() === 'Hello WebSocket' ||
|
||||||
|
message.toString() === 'Echo: Hello WebSocket'
|
||||||
|
) {
|
||||||
console.log('[TEST] Message received correctly');
|
console.log('[TEST] Message received correctly');
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
cleanup();
|
cleanup();
|
||||||
@ -320,7 +356,7 @@ tap.test('should support WebSocket connections', async () => {
|
|||||||
ws.on('close', (code, reason) => {
|
ws.on('close', (code, reason) => {
|
||||||
console.log('[TEST] WebSocket connection closed:', {
|
console.log('[TEST] WebSocket connection closed:', {
|
||||||
code,
|
code,
|
||||||
reason: reason.toString()
|
reason: reason.toString(),
|
||||||
});
|
});
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
@ -328,15 +364,18 @@ tap.test('should support WebSocket connections', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should handle custom headers', async () => {
|
tap.test('should handle custom headers', async () => {
|
||||||
testProxy.addDefaultHeaders({
|
await testProxy.addDefaultHeaders({
|
||||||
'X-Proxy-Header': 'test-value',
|
'X-Proxy-Header': 'test-value',
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await makeHttpsRequest({
|
const response = await makeHttpsRequest({
|
||||||
hostname: 'push.rocks',
|
hostname: 'localhost', // changed to 'localhost'
|
||||||
port: 3001,
|
port: 3001,
|
||||||
path: '/',
|
path: '/',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
host: 'push.rocks', // still routing to push.rocks
|
||||||
|
},
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -353,16 +392,20 @@ tap.test('cleanup', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TEST] Closing WebSocket server');
|
console.log('[TEST] Closing WebSocket server');
|
||||||
await new Promise<void>((resolve) => wsServer.close(() => {
|
await new Promise<void>((resolve) =>
|
||||||
console.log('[TEST] WebSocket server closed');
|
wsServer.close(() => {
|
||||||
resolve();
|
console.log('[TEST] WebSocket server closed');
|
||||||
}));
|
resolve();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
console.log('[TEST] Closing test server');
|
console.log('[TEST] Closing test server');
|
||||||
await new Promise<void>((resolve) => testServer.close(() => {
|
await new Promise<void>((resolve) =>
|
||||||
console.log('[TEST] Test server closed');
|
testServer.close(() => {
|
||||||
resolve();
|
console.log('[TEST] Test server closed');
|
||||||
}));
|
resolve();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
console.log('[TEST] Stopping proxy');
|
console.log('[TEST] Stopping proxy');
|
||||||
await testProxy.stop();
|
await testProxy.stop();
|
||||||
@ -376,4 +419,4 @@ process.on('exit', () => {
|
|||||||
testProxy.stop().then(() => console.log('[TEST] Proxy server stopped'));
|
testProxy.stop().then(() => console.log('[TEST] Proxy server stopped'));
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.start();
|
tap.start();
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '3.1.2',
|
version: '3.10.5',
|
||||||
description: 'a proxy for handling high workloads of proxying'
|
description: 'a proxy for handling high workloads of proxying'
|
||||||
}
|
}
|
||||||
|
369
ts/classes.networkproxy.ts
Normal file
369
ts/classes.networkproxy.ts
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { ProxyRouter } from './classes.router.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
export interface INetworkProxyOptions {
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IWebSocketWithHeartbeat extends plugins.wsDefault {
|
||||||
|
lastPong: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NetworkProxy {
|
||||||
|
public options: INetworkProxyOptions;
|
||||||
|
public proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
||||||
|
public httpsServer: plugins.https.Server;
|
||||||
|
public router = new ProxyRouter();
|
||||||
|
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
||||||
|
public defaultHeaders: { [key: string]: string } = {};
|
||||||
|
public heartbeatInterval: NodeJS.Timeout;
|
||||||
|
private defaultCertificates: { key: string; cert: string };
|
||||||
|
|
||||||
|
public alreadyAddedReverseConfigs: {
|
||||||
|
[hostName: string]: plugins.tsclass.network.IReverseProxyConfig;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
constructor(optionsArg: INetworkProxyOptions) {
|
||||||
|
this.options = optionsArg;
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const certPath = path.join(__dirname, '..', 'assets', 'certs');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.defaultCertificates = {
|
||||||
|
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
||||||
|
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading certificates:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
// Instead of marking the callback async (which Node won't await),
|
||||||
|
// we call our async handler and catch errors.
|
||||||
|
this.httpsServer = plugins.https.createServer(
|
||||||
|
{
|
||||||
|
key: this.defaultCertificates.key,
|
||||||
|
cert: this.defaultCertificates.cert
|
||||||
|
},
|
||||||
|
(originRequest, originResponse) => {
|
||||||
|
this.handleRequest(originRequest, originResponse).catch((error) => {
|
||||||
|
console.error('Unhandled error in request handler:', error);
|
||||||
|
try {
|
||||||
|
originResponse.end();
|
||||||
|
} catch (err) {
|
||||||
|
// ignore errors during cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable websockets
|
||||||
|
const wsServer = new plugins.ws.WebSocketServer({ server: this.httpsServer });
|
||||||
|
|
||||||
|
// Set up the heartbeat interval
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
wsServer.clients.forEach((ws: plugins.wsDefault) => {
|
||||||
|
const wsIncoming = ws as IWebSocketWithHeartbeat;
|
||||||
|
if (!wsIncoming.lastPong) {
|
||||||
|
wsIncoming.lastPong = Date.now();
|
||||||
|
}
|
||||||
|
if (Date.now() - wsIncoming.lastPong > 5 * 60 * 1000) {
|
||||||
|
console.log('Terminating websocket due to missing pong for 5 minutes.');
|
||||||
|
wsIncoming.terminate();
|
||||||
|
} else {
|
||||||
|
wsIncoming.ping();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 60000); // runs every 1 minute
|
||||||
|
|
||||||
|
wsServer.on(
|
||||||
|
'connection',
|
||||||
|
(wsIncoming: IWebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
|
||||||
|
console.log(
|
||||||
|
`wss proxy: got connection for wsc for https://${reqArg.headers.host}${reqArg.url}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
wsIncoming.lastPong = Date.now();
|
||||||
|
wsIncoming.on('pong', () => {
|
||||||
|
wsIncoming.lastPong = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
let wsOutgoing: plugins.wsDefault;
|
||||||
|
const outGoingDeferred = plugins.smartpromise.defer();
|
||||||
|
|
||||||
|
// --- Improvement 2: Only call routeReq once ---
|
||||||
|
const wsDestinationConfig = this.router.routeReq(reqArg);
|
||||||
|
if (!wsDestinationConfig) {
|
||||||
|
wsIncoming.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
wsOutgoing = new plugins.wsDefault(
|
||||||
|
`ws://${wsDestinationConfig.destinationIp}:${wsDestinationConfig.destinationPort}${reqArg.url}`,
|
||||||
|
);
|
||||||
|
console.log('wss proxy: initiated outgoing proxy');
|
||||||
|
wsOutgoing.on('open', async () => {
|
||||||
|
outGoingDeferred.resolve();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error initiating outgoing WebSocket:', err);
|
||||||
|
wsIncoming.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wsIncoming.on('message', async (message, isBinary) => {
|
||||||
|
try {
|
||||||
|
await outGoingDeferred.promise;
|
||||||
|
wsOutgoing.send(message, { binary: isBinary });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message to wsOutgoing:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wsOutgoing.on('message', async (message, isBinary) => {
|
||||||
|
try {
|
||||||
|
wsIncoming.send(message, { binary: isBinary });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message to wsIncoming:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const terminateWsOutgoing = () => {
|
||||||
|
if (wsOutgoing) {
|
||||||
|
wsOutgoing.terminate();
|
||||||
|
console.log('Terminated outgoing ws.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wsIncoming.on('error', terminateWsOutgoing);
|
||||||
|
wsIncoming.on('close', terminateWsOutgoing);
|
||||||
|
|
||||||
|
const terminateWsIncoming = () => {
|
||||||
|
if (wsIncoming) {
|
||||||
|
wsIncoming.terminate();
|
||||||
|
console.log('Terminated incoming ws.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wsOutgoing.on('error', terminateWsIncoming);
|
||||||
|
wsOutgoing.on('close', terminateWsIncoming);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.httpsServer.keepAliveTimeout = 600 * 1000;
|
||||||
|
this.httpsServer.headersTimeout = 600 * 1000;
|
||||||
|
|
||||||
|
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
||||||
|
this.socketMap.add(connection);
|
||||||
|
console.log(`Added connection. Now ${this.socketMap.getArray().length} sockets connected.`);
|
||||||
|
const cleanupConnection = () => {
|
||||||
|
if (this.socketMap.checkForObject(connection)) {
|
||||||
|
this.socketMap.remove(connection);
|
||||||
|
console.log(`Removed connection. ${this.socketMap.getArray().length} sockets remaining.`);
|
||||||
|
connection.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
connection.on('close', cleanupConnection);
|
||||||
|
connection.on('error', cleanupConnection);
|
||||||
|
connection.on('end', cleanupConnection);
|
||||||
|
connection.on('timeout', cleanupConnection);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.httpsServer.listen(this.options.port);
|
||||||
|
console.log(
|
||||||
|
`NetworkProxy -> OK: now listening for new connections on port ${this.options.port}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal async handler for processing HTTP/HTTPS requests.
|
||||||
|
*/
|
||||||
|
private async handleRequest(
|
||||||
|
originRequest: plugins.http.IncomingMessage,
|
||||||
|
originResponse: plugins.http.ServerResponse,
|
||||||
|
): Promise<void> {
|
||||||
|
const endOriginReqRes = (
|
||||||
|
statusArg: number = 404,
|
||||||
|
messageArg: string = 'This route is not available on this server.',
|
||||||
|
headers: plugins.http.OutgoingHttpHeaders = {},
|
||||||
|
) => {
|
||||||
|
originResponse.writeHead(statusArg, messageArg);
|
||||||
|
originResponse.end(messageArg);
|
||||||
|
if (originRequest.socket !== originResponse.socket) {
|
||||||
|
console.log('hey, something is strange.');
|
||||||
|
}
|
||||||
|
originResponse.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`got request: ${originRequest.headers.host}${plugins.url.parse(originRequest.url).path}`,
|
||||||
|
);
|
||||||
|
const destinationConfig = this.router.routeReq(originRequest);
|
||||||
|
|
||||||
|
if (!destinationConfig) {
|
||||||
|
console.log(
|
||||||
|
`${originRequest.headers.host} can't be routed properly. Terminating request.`,
|
||||||
|
);
|
||||||
|
endOriginReqRes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// authentication
|
||||||
|
if (destinationConfig.authentication) {
|
||||||
|
const authInfo = destinationConfig.authentication;
|
||||||
|
switch (authInfo.type) {
|
||||||
|
case 'Basic': {
|
||||||
|
const authHeader = originRequest.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return endOriginReqRes(401, 'Authentication required', {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!authHeader.includes('Basic ')) {
|
||||||
|
return endOriginReqRes(401, 'Authentication required', {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const authStringBase64 = authHeader.replace('Basic ', '');
|
||||||
|
const authString: string = plugins.smartstring.base64.decode(authStringBase64);
|
||||||
|
const userPassArray = authString.split(':');
|
||||||
|
const user = userPassArray[0];
|
||||||
|
const pass = userPassArray[1];
|
||||||
|
if (user === authInfo.user && pass === authInfo.pass) {
|
||||||
|
console.log('Request successfully authenticated');
|
||||||
|
} else {
|
||||||
|
return endOriginReqRes(403, 'Forbidden: Wrong credentials');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return endOriginReqRes(
|
||||||
|
403,
|
||||||
|
'Forbidden: unsupported authentication method configured. Please report to the admin.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationUrl: string;
|
||||||
|
if (destinationConfig) {
|
||||||
|
destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
||||||
|
} else {
|
||||||
|
return endOriginReqRes();
|
||||||
|
}
|
||||||
|
console.log(destinationUrl);
|
||||||
|
try {
|
||||||
|
const proxyResponse = await plugins.smartrequest.request(
|
||||||
|
destinationUrl,
|
||||||
|
{
|
||||||
|
method: originRequest.method,
|
||||||
|
headers: {
|
||||||
|
...originRequest.headers,
|
||||||
|
'X-Forwarded-Host': originRequest.headers.host,
|
||||||
|
'X-Forwarded-Proto': 'https',
|
||||||
|
},
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
true, // streaming (keepAlive)
|
||||||
|
(proxyRequest) => {
|
||||||
|
originRequest.on('data', (data) => {
|
||||||
|
proxyRequest.write(data);
|
||||||
|
});
|
||||||
|
originRequest.on('end', () => {
|
||||||
|
proxyRequest.end();
|
||||||
|
});
|
||||||
|
originRequest.on('error', () => {
|
||||||
|
proxyRequest.end();
|
||||||
|
});
|
||||||
|
originRequest.on('close', () => {
|
||||||
|
proxyRequest.end();
|
||||||
|
});
|
||||||
|
originRequest.on('timeout', () => {
|
||||||
|
proxyRequest.end();
|
||||||
|
originRequest.destroy();
|
||||||
|
});
|
||||||
|
proxyRequest.on('error', () => {
|
||||||
|
endOriginReqRes();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
originResponse.statusCode = proxyResponse.statusCode;
|
||||||
|
console.log(proxyResponse.statusCode);
|
||||||
|
for (const defaultHeader of Object.keys(this.defaultHeaders)) {
|
||||||
|
originResponse.setHeader(defaultHeader, this.defaultHeaders[defaultHeader]);
|
||||||
|
}
|
||||||
|
for (const header of Object.keys(proxyResponse.headers)) {
|
||||||
|
originResponse.setHeader(header, proxyResponse.headers[header]);
|
||||||
|
}
|
||||||
|
proxyResponse.on('data', (data) => {
|
||||||
|
originResponse.write(data);
|
||||||
|
});
|
||||||
|
proxyResponse.on('end', () => {
|
||||||
|
originResponse.end();
|
||||||
|
});
|
||||||
|
proxyResponse.on('error', () => {
|
||||||
|
originResponse.destroy();
|
||||||
|
});
|
||||||
|
proxyResponse.on('close', () => {
|
||||||
|
originResponse.end();
|
||||||
|
});
|
||||||
|
proxyResponse.on('timeout', () => {
|
||||||
|
originResponse.end();
|
||||||
|
originResponse.destroy();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error while processing request:', error);
|
||||||
|
endOriginReqRes(502, 'Bad Gateway: Error processing the request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProxyConfigs(
|
||||||
|
proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[],
|
||||||
|
) {
|
||||||
|
console.log(`got new proxy configs`);
|
||||||
|
this.proxyConfigs = proxyConfigsArg;
|
||||||
|
this.router.setNewProxyConfigs(proxyConfigsArg);
|
||||||
|
for (const hostCandidate of this.proxyConfigs) {
|
||||||
|
const existingHostNameConfig = this.alreadyAddedReverseConfigs[hostCandidate.hostName];
|
||||||
|
|
||||||
|
if (!existingHostNameConfig) {
|
||||||
|
this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
existingHostNameConfig.publicKey === hostCandidate.publicKey &&
|
||||||
|
existingHostNameConfig.privateKey === hostCandidate.privateKey
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.httpsServer.addContext(hostCandidate.hostName, {
|
||||||
|
cert: hostCandidate.publicKey,
|
||||||
|
key: hostCandidate.privateKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addDefaultHeaders(headersArg: { [key: string]: string }) {
|
||||||
|
for (const headerKey of Object.keys(headersArg)) {
|
||||||
|
this.defaultHeaders[headerKey] = headersArg[headerKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
const done = plugins.smartpromise.defer();
|
||||||
|
this.httpsServer.close(() => {
|
||||||
|
done.resolve();
|
||||||
|
});
|
||||||
|
for (const socket of this.socketMap.getArray()) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
await done.promise;
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
console.log('NetworkProxy -> OK: Server has been stopped and all connections closed.');
|
||||||
|
}
|
||||||
|
}
|
212
ts/classes.port80handler.ts
Normal file
212
ts/classes.port80handler.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
import * as acme from 'acme-client';
|
||||||
|
|
||||||
|
interface IDomainCertificate {
|
||||||
|
certObtained: boolean;
|
||||||
|
obtainingInProgress: boolean;
|
||||||
|
certificate?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
challengeToken?: string;
|
||||||
|
challengeKeyAuthorization?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Port80Handler {
|
||||||
|
private domainCertificates: Map<string, IDomainCertificate>;
|
||||||
|
private server: http.Server;
|
||||||
|
private acmeClient: acme.Client | null = null;
|
||||||
|
private accountKey: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
||||||
|
|
||||||
|
// Create and start an HTTP server on port 80.
|
||||||
|
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||||
|
this.server.listen(80, () => {
|
||||||
|
console.log('Port80Handler is listening on port 80');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a domain to be managed.
|
||||||
|
* @param domain The domain to add.
|
||||||
|
*/
|
||||||
|
public addDomain(domain: string): void {
|
||||||
|
if (!this.domainCertificates.has(domain)) {
|
||||||
|
this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false });
|
||||||
|
console.log(`Domain added: ${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a domain from management.
|
||||||
|
* @param domain The domain to remove.
|
||||||
|
*/
|
||||||
|
public removeDomain(domain: string): void {
|
||||||
|
if (this.domainCertificates.delete(domain)) {
|
||||||
|
console.log(`Domain removed: ${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy initialization of the ACME client.
|
||||||
|
* Uses Let’s Encrypt’s production directory (for testing you might switch to staging).
|
||||||
|
*/
|
||||||
|
private async getAcmeClient(): Promise<acme.Client> {
|
||||||
|
if (this.acmeClient) {
|
||||||
|
return this.acmeClient;
|
||||||
|
}
|
||||||
|
// Generate a new account key.
|
||||||
|
this.accountKey = await acme.forge.createPrivateKey();
|
||||||
|
this.acmeClient = new acme.Client({
|
||||||
|
directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate
|
||||||
|
// For testing, you could use:
|
||||||
|
// directoryUrl: acme.directory.letsencrypt.staging,
|
||||||
|
accountKey: this.accountKey,
|
||||||
|
});
|
||||||
|
// Create a new account. Make sure to update the contact email.
|
||||||
|
await this.acmeClient.createAccount({
|
||||||
|
termsOfServiceAgreed: true,
|
||||||
|
contact: ['mailto:admin@example.com'],
|
||||||
|
});
|
||||||
|
return this.acmeClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming HTTP requests on port 80.
|
||||||
|
* If the request is for an ACME challenge, it responds with the key authorization.
|
||||||
|
* If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance.
|
||||||
|
*/
|
||||||
|
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
|
const hostHeader = req.headers.host;
|
||||||
|
if (!hostHeader) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Bad Request: Host header is missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Extract domain (ignoring any port in the Host header)
|
||||||
|
const domain = hostHeader.split(':')[0];
|
||||||
|
|
||||||
|
// If the request is for an ACME HTTP-01 challenge, handle it.
|
||||||
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
||||||
|
this.handleAcmeChallenge(req, res, domain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.domainCertificates.has(domain)) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Domain not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainInfo = this.domainCertificates.get(domain)!;
|
||||||
|
|
||||||
|
// If certificate exists, redirect to HTTPS on port 443.
|
||||||
|
if (domainInfo.certObtained) {
|
||||||
|
const redirectUrl = `https://${domain}:443${req.url}`;
|
||||||
|
res.statusCode = 301;
|
||||||
|
res.setHeader('Location', redirectUrl);
|
||||||
|
res.end(`Redirecting to ${redirectUrl}`);
|
||||||
|
} else {
|
||||||
|
// Trigger certificate issuance if not already running.
|
||||||
|
if (!domainInfo.obtainingInProgress) {
|
||||||
|
domainInfo.obtainingInProgress = true;
|
||||||
|
this.obtainCertificate(domain).catch(err => {
|
||||||
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.end('Certificate issuance in progress, please try again later.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the ACME HTTP-01 challenge response.
|
||||||
|
*/
|
||||||
|
private handleAcmeChallenge(req: http.IncomingMessage, res: http.ServerResponse, domain: string): void {
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
if (!domainInfo) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Domain not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The token is the last part of the URL.
|
||||||
|
const urlParts = req.url?.split('/');
|
||||||
|
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
||||||
|
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end(domainInfo.challengeKeyAuthorization);
|
||||||
|
console.log(`Served ACME challenge response for ${domain}`);
|
||||||
|
} else {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Challenge token not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses acme-client to perform a full ACME HTTP-01 challenge to obtain a certificate.
|
||||||
|
* On success, it stores the certificate and key in memory and clears challenge data.
|
||||||
|
*/
|
||||||
|
private async obtainCertificate(domain: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const client = await this.getAcmeClient();
|
||||||
|
|
||||||
|
// Create a new order for the domain.
|
||||||
|
const order = await client.createOrder({
|
||||||
|
identifiers: [{ type: 'dns', value: domain }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the authorizations for the order.
|
||||||
|
const authorizations = await client.getAuthorizations(order);
|
||||||
|
for (const authz of authorizations) {
|
||||||
|
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
||||||
|
if (!challenge) {
|
||||||
|
throw new Error('HTTP-01 challenge not found');
|
||||||
|
}
|
||||||
|
// Get the key authorization for the challenge.
|
||||||
|
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
||||||
|
const domainInfo = this.domainCertificates.get(domain)!;
|
||||||
|
domainInfo.challengeToken = challenge.token;
|
||||||
|
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
||||||
|
|
||||||
|
// Notify the ACME server that the challenge is ready.
|
||||||
|
await client.verifyChallenge(authz, challenge, keyAuthorization);
|
||||||
|
await client.completeChallenge(challenge);
|
||||||
|
// Wait until the challenge is validated.
|
||||||
|
await client.waitForValidStatus(challenge);
|
||||||
|
console.log(`HTTP-01 challenge completed for ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a CSR and a new private key for the domain.
|
||||||
|
const [csr, privateKey] = await acme.forge.createCsr({
|
||||||
|
commonName: domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finalize the order and obtain the certificate.
|
||||||
|
await client.finalizeOrder(order, csr);
|
||||||
|
const certificate = await client.getCertificate(order);
|
||||||
|
|
||||||
|
const domainInfo = this.domainCertificates.get(domain)!;
|
||||||
|
domainInfo.certificate = certificate;
|
||||||
|
domainInfo.privateKey = privateKey;
|
||||||
|
domainInfo.certObtained = true;
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
delete domainInfo.challengeToken;
|
||||||
|
delete domainInfo.challengeKeyAuthorization;
|
||||||
|
|
||||||
|
console.log(`Certificate obtained for ${domain}`);
|
||||||
|
// In a real application, you would persist the certificate and key,
|
||||||
|
// then reload your TLS server with the new credentials.
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||||
|
const domainInfo = this.domainCertificates.get(domain);
|
||||||
|
if (domainInfo) {
|
||||||
|
domainInfo.obtainingInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage:
|
||||||
|
// const handler = new Port80Handler();
|
||||||
|
// handler.addDomain('example.com');
|
352
ts/classes.portproxy.ts
Normal file
352
ts/classes.portproxy.ts
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
export interface IDomainConfig {
|
||||||
|
domain: string; // Glob pattern for domain
|
||||||
|
allowedIPs: string[]; // Glob patterns for allowed IPs
|
||||||
|
targetIP?: string; // Optional target IP for this domain
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProxySettings extends plugins.tls.TlsOptions {
|
||||||
|
fromPort: number;
|
||||||
|
toPort: number;
|
||||||
|
toHost?: string; // Target host to proxy to, defaults to 'localhost'
|
||||||
|
domains: IDomainConfig[];
|
||||||
|
sniEnabled?: boolean;
|
||||||
|
defaultAllowedIPs?: string[];
|
||||||
|
preserveSourceIP?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
|
||||||
|
* @param buffer - Buffer containing the TLS ClientHello.
|
||||||
|
* @returns The server name if found, otherwise undefined.
|
||||||
|
*/
|
||||||
|
function extractSNI(buffer: Buffer): string | undefined {
|
||||||
|
let offset = 0;
|
||||||
|
if (buffer.length < 5) return undefined;
|
||||||
|
|
||||||
|
const recordType = buffer.readUInt8(0);
|
||||||
|
if (recordType !== 22) return undefined; // 22 = handshake
|
||||||
|
|
||||||
|
const recordLength = buffer.readUInt16BE(3);
|
||||||
|
if (buffer.length < 5 + recordLength) return undefined;
|
||||||
|
|
||||||
|
offset = 5;
|
||||||
|
const handshakeType = buffer.readUInt8(offset);
|
||||||
|
if (handshakeType !== 1) return undefined; // 1 = ClientHello
|
||||||
|
|
||||||
|
offset += 4; // Skip handshake header (type + length)
|
||||||
|
offset += 2 + 32; // Skip client version and random
|
||||||
|
|
||||||
|
const sessionIDLength = buffer.readUInt8(offset);
|
||||||
|
offset += 1 + sessionIDLength; // Skip session ID
|
||||||
|
|
||||||
|
const cipherSuitesLength = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2 + cipherSuitesLength; // Skip cipher suites
|
||||||
|
|
||||||
|
const compressionMethodsLength = buffer.readUInt8(offset);
|
||||||
|
offset += 1 + compressionMethodsLength; // Skip compression methods
|
||||||
|
|
||||||
|
if (offset + 2 > buffer.length) return undefined;
|
||||||
|
const extensionsLength = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const extensionsEnd = offset + extensionsLength;
|
||||||
|
|
||||||
|
while (offset + 4 <= extensionsEnd) {
|
||||||
|
const extensionType = buffer.readUInt16BE(offset);
|
||||||
|
const extensionLength = buffer.readUInt16BE(offset + 2);
|
||||||
|
offset += 4;
|
||||||
|
if (extensionType === 0x0000) { // SNI extension
|
||||||
|
if (offset + 2 > buffer.length) return undefined;
|
||||||
|
const sniListLength = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
const sniListEnd = offset + sniListLength;
|
||||||
|
while (offset + 3 < sniListEnd) {
|
||||||
|
const nameType = buffer.readUInt8(offset++);
|
||||||
|
const nameLen = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
if (nameType === 0) { // host_name
|
||||||
|
if (offset + nameLen > buffer.length) return undefined;
|
||||||
|
return buffer.toString('utf8', offset, offset + nameLen);
|
||||||
|
}
|
||||||
|
offset += nameLen;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
offset += extensionLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IConnectionRecord {
|
||||||
|
incoming: plugins.net.Socket;
|
||||||
|
outgoing: plugins.net.Socket | null;
|
||||||
|
incomingStartTime: number;
|
||||||
|
outgoingStartTime?: number;
|
||||||
|
connectionClosed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortProxy {
|
||||||
|
netServer: plugins.net.Server;
|
||||||
|
settings: IProxySettings;
|
||||||
|
// Unified record tracking each connection pair.
|
||||||
|
private connectionRecords: Set<IConnectionRecord> = new Set();
|
||||||
|
private connectionLogger: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
private terminationStats: {
|
||||||
|
incoming: Record<string, number>;
|
||||||
|
outgoing: Record<string, number>;
|
||||||
|
} = {
|
||||||
|
incoming: {},
|
||||||
|
outgoing: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(settings: IProxySettings) {
|
||||||
|
this.settings = {
|
||||||
|
...settings,
|
||||||
|
toHost: settings.toHost || 'localhost',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
||||||
|
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find a matching domain config based on the SNI.
|
||||||
|
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 connectionRecord: IConnectionRecord = {
|
||||||
|
incoming: socket,
|
||||||
|
outgoing: null,
|
||||||
|
incomingStartTime: Date.now(),
|
||||||
|
connectionClosed: false,
|
||||||
|
};
|
||||||
|
this.connectionRecords.add(connectionRecord);
|
||||||
|
console.log(`New connection from ${remoteIP}. 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 = () => {
|
||||||
|
if (!connectionRecord.connectionClosed) {
|
||||||
|
connectionRecord.connectionClosed = true;
|
||||||
|
cleanUpSockets(connectionRecord.incoming, connectionRecord.outgoing || undefined);
|
||||||
|
this.connectionRecords.delete(connectionRecord);
|
||||||
|
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to reject an incoming connection.
|
||||||
|
const rejectIncomingConnection = (reason: string, logMessage: string) => {
|
||||||
|
console.log(logMessage);
|
||||||
|
socket.end();
|
||||||
|
if (incomingTerminationReason === null) {
|
||||||
|
incomingTerminationReason = reason;
|
||||||
|
this.incrementTerminationStat('incoming', reason);
|
||||||
|
}
|
||||||
|
cleanupOnce();
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
|
||||||
|
const code = (err as any).code;
|
||||||
|
let reason = 'error';
|
||||||
|
if (code === 'ECONNRESET') {
|
||||||
|
reason = 'econnreset';
|
||||||
|
console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
|
||||||
|
}
|
||||||
|
if (side === 'incoming' && incomingTerminationReason === null) {
|
||||||
|
incomingTerminationReason = reason;
|
||||||
|
this.incrementTerminationStat('incoming', reason);
|
||||||
|
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
|
||||||
|
outgoingTerminationReason = reason;
|
||||||
|
this.incrementTerminationStat('outgoing', reason);
|
||||||
|
}
|
||||||
|
cleanupOnce();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (side: 'incoming' | 'outgoing') => () => {
|
||||||
|
console.log(`Connection closed on ${side} side from ${remoteIP}`);
|
||||||
|
if (side === 'incoming' && incomingTerminationReason === null) {
|
||||||
|
incomingTerminationReason = 'normal';
|
||||||
|
this.incrementTerminationStat('incoming', 'normal');
|
||||||
|
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
|
||||||
|
outgoingTerminationReason = 'normal';
|
||||||
|
this.incrementTerminationStat('outgoing', 'normal');
|
||||||
|
}
|
||||||
|
cleanupOnce();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupConnection = (serverName: string, initialChunk?: Buffer) => {
|
||||||
|
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
|
||||||
|
|
||||||
|
if (!defaultAllowed && serverName) {
|
||||||
|
const domainConfig = findMatchingDomain(serverName);
|
||||||
|
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) {
|
||||||
|
return rejectIncomingConnection('rejected', `Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`);
|
||||||
|
} else if (defaultAllowed && !serverName) {
|
||||||
|
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainConfig = serverName ? findMatchingDomain(serverName) : undefined;
|
||||||
|
const targetHost = domainConfig?.targetIP || this.settings.toHost!;
|
||||||
|
const connectionOptions: plugins.net.NetConnectOpts = {
|
||||||
|
host: targetHost,
|
||||||
|
port: this.settings.toPort,
|
||||||
|
};
|
||||||
|
if (this.settings.preserveSourceIP) {
|
||||||
|
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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})` : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialChunk) {
|
||||||
|
socket.unshift(initialChunk);
|
||||||
|
}
|
||||||
|
socket.setTimeout(120000);
|
||||||
|
socket.pipe(targetSocket);
|
||||||
|
targetSocket.pipe(socket);
|
||||||
|
|
||||||
|
socket.on('error', handleError('incoming'));
|
||||||
|
targetSocket.on('error', handleError('outgoing'));
|
||||||
|
socket.on('close', handleClose('incoming'));
|
||||||
|
targetSocket.on('close', handleClose('outgoing'));
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
console.log(`Timeout on incoming side from ${remoteIP}`);
|
||||||
|
if (incomingTerminationReason === null) {
|
||||||
|
incomingTerminationReason = 'timeout';
|
||||||
|
this.incrementTerminationStat('incoming', 'timeout');
|
||||||
|
}
|
||||||
|
cleanupOnce();
|
||||||
|
});
|
||||||
|
targetSocket.on('timeout', () => {
|
||||||
|
console.log(`Timeout on outgoing side from ${remoteIP}`);
|
||||||
|
if (outgoingTerminationReason === null) {
|
||||||
|
outgoingTerminationReason = 'timeout';
|
||||||
|
this.incrementTerminationStat('outgoing', 'timeout');
|
||||||
|
}
|
||||||
|
cleanupOnce();
|
||||||
|
});
|
||||||
|
socket.on('end', handleClose('incoming'));
|
||||||
|
targetSocket.on('end', handleClose('outgoing'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.settings.sniEnabled) {
|
||||||
|
socket.setTimeout(5000, () => {
|
||||||
|
console.log(`Initial data timeout for ${remoteIP}`);
|
||||||
|
socket.end();
|
||||||
|
cleanupOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once('data', (chunk: Buffer) => {
|
||||||
|
socket.setTimeout(0);
|
||||||
|
initialDataReceived = true;
|
||||||
|
const serverName = extractSNI(chunk) || '';
|
||||||
|
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
|
||||||
|
setupConnection(serverName, chunk);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
initialDataReceived = true;
|
||||||
|
if (!this.settings.defaultAllowedIPs || !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)' : ''}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every 10 seconds log active connection count and longest running durations.
|
||||||
|
this.connectionLogger = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
let maxIncoming = 0;
|
||||||
|
let maxOutgoing = 0;
|
||||||
|
for (const record of this.connectionRecords) {
|
||||||
|
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
|
||||||
|
if (record.outgoingStartTime) {
|
||||||
|
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
|
||||||
|
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
|
||||||
|
`Termination stats (incoming): ${JSON.stringify(this.terminationStats.incoming)}, ` +
|
||||||
|
`(outgoing): ${JSON.stringify(this.terminationStats.outgoing)}`
|
||||||
|
);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
const done = plugins.smartpromise.defer();
|
||||||
|
this.netServer.close(() => {
|
||||||
|
done.resolve();
|
||||||
|
});
|
||||||
|
if (this.connectionLogger) {
|
||||||
|
clearInterval(this.connectionLogger);
|
||||||
|
this.connectionLogger = null;
|
||||||
|
}
|
||||||
|
await done.promise;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
export class ProxyRouter {
|
export class ProxyRouter {
|
||||||
public reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
public reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
@ -1,4 +1,4 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
export class SslRedirect {
|
export class SslRedirect {
|
||||||
httpServer: plugins.http.Server;
|
httpServer: plugins.http.Server;
|
@ -1,3 +1,3 @@
|
|||||||
export * from './smartproxy.classes.networkproxy.js';
|
export * from './classes.networkproxy.js';
|
||||||
export * from './smartproxy.portproxy.js';
|
export * from './classes.portproxy.js';
|
||||||
export * from './smartproxy.classes.sslredirect.js';
|
export * from './classes.sslredirect.js';
|
||||||
|
@ -2,9 +2,10 @@
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
|
import * as tls from 'tls';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
|
|
||||||
export { http, https, net, url };
|
export { http, https, net, tls, url };
|
||||||
|
|
||||||
// tsclass scope
|
// tsclass scope
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
@ -21,7 +22,9 @@ import * as smartstring from '@push.rocks/smartstring';
|
|||||||
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
||||||
|
|
||||||
// third party scope
|
// third party scope
|
||||||
|
import prettyMs from 'pretty-ms';
|
||||||
import * as ws from 'ws';
|
import * as ws from 'ws';
|
||||||
import wsDefault from 'ws';
|
import wsDefault from 'ws';
|
||||||
|
import { minimatch } from 'minimatch';
|
||||||
|
|
||||||
export { wsDefault, ws };
|
export { prettyMs, ws, wsDefault, minimatch };
|
@ -1,357 +0,0 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
|
||||||
import { ProxyRouter } from './smartproxy.classes.router.js';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
export interface INetworkProxyOptions {
|
|
||||||
port: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebSocketWithHeartbeat extends plugins.wsDefault {
|
|
||||||
lastPong: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NetworkProxy {
|
|
||||||
// INSTANCE
|
|
||||||
public options: INetworkProxyOptions;
|
|
||||||
public proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
|
|
||||||
public httpsServer: plugins.https.Server;
|
|
||||||
public router = new ProxyRouter();
|
|
||||||
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
|
||||||
public defaultHeaders: { [key: string]: string } = {};
|
|
||||||
public heartbeatInterval: NodeJS.Timeout;
|
|
||||||
private defaultCertificates: { key: string; cert: string };
|
|
||||||
|
|
||||||
public alreadyAddedReverseConfigs: {
|
|
||||||
[hostName: string]: plugins.tsclass.network.IReverseProxyConfig;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
constructor(optionsArg: INetworkProxyOptions) {
|
|
||||||
this.options = optionsArg;
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const certPath = path.join(__dirname, '..', 'assets', 'certs');
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.defaultCertificates = {
|
|
||||||
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
|
|
||||||
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading certificates:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* starts the proxyInstance
|
|
||||||
*/
|
|
||||||
public async start() {
|
|
||||||
this.httpsServer = plugins.https.createServer(
|
|
||||||
{
|
|
||||||
key: this.defaultCertificates.key,
|
|
||||||
cert: this.defaultCertificates.cert
|
|
||||||
},
|
|
||||||
async (originRequest, originResponse) => {
|
|
||||||
/**
|
|
||||||
* endRequest function
|
|
||||||
* can be used to prematurely end a request
|
|
||||||
*/
|
|
||||||
const endOriginReqRes = (
|
|
||||||
statusArg: number = 404,
|
|
||||||
messageArg: string = 'This route is not available on this server.',
|
|
||||||
headers: plugins.http.OutgoingHttpHeaders = {},
|
|
||||||
) => {
|
|
||||||
originResponse.writeHead(statusArg, messageArg);
|
|
||||||
originResponse.end(messageArg);
|
|
||||||
if (originRequest.socket !== originResponse.socket) {
|
|
||||||
console.log('hey, something is strange.');
|
|
||||||
}
|
|
||||||
originResponse.destroy();
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`got request: ${originRequest.headers.host}${plugins.url.parse(originRequest.url).path}`,
|
|
||||||
);
|
|
||||||
const destinationConfig = this.router.routeReq(originRequest);
|
|
||||||
|
|
||||||
if (!destinationConfig) {
|
|
||||||
console.log(
|
|
||||||
`${originRequest.headers.host} can't be routed properly. Terminating request.`,
|
|
||||||
);
|
|
||||||
endOriginReqRes();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// authentication
|
|
||||||
if (destinationConfig.authentication) {
|
|
||||||
const authInfo = destinationConfig.authentication;
|
|
||||||
switch (authInfo.type) {
|
|
||||||
case 'Basic':
|
|
||||||
const authHeader = originRequest.headers.authorization;
|
|
||||||
if (authHeader) {
|
|
||||||
if (!authHeader.includes('Basic ')) {
|
|
||||||
return endOriginReqRes(401, 'Authentication required', {
|
|
||||||
'WWW-Authenticate': 'Basic realm="Access to the staging site", charset="UTF-8"',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const authStringBase64 = originRequest.headers.authorization.replace('Basic ', '');
|
|
||||||
const authString: string = plugins.smartstring.base64.decode(authStringBase64);
|
|
||||||
const userPassArray = authString.split(':');
|
|
||||||
const user = userPassArray[0];
|
|
||||||
const pass = userPassArray[1];
|
|
||||||
if (user === authInfo.user && pass === authInfo.pass) {
|
|
||||||
console.log('request successfully authenticated');
|
|
||||||
} else {
|
|
||||||
return endOriginReqRes(403, 'Forbidden: Wrong credentials');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return endOriginReqRes(
|
|
||||||
403,
|
|
||||||
'Forbidden: unsupported authentication method configured. Please report to the admin.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let destinationUrl: string;
|
|
||||||
if (destinationConfig) {
|
|
||||||
destinationUrl = `http://${destinationConfig.destinationIp}:${destinationConfig.destinationPort}${originRequest.url}`;
|
|
||||||
} else {
|
|
||||||
return endOriginReqRes();
|
|
||||||
}
|
|
||||||
console.log(destinationUrl);
|
|
||||||
try {
|
|
||||||
const proxyResponse = await plugins.smartrequest.request(
|
|
||||||
destinationUrl,
|
|
||||||
{
|
|
||||||
method: originRequest.method,
|
|
||||||
headers: {
|
|
||||||
...originRequest.headers,
|
|
||||||
'X-Forwarded-Host': originRequest.headers.host,
|
|
||||||
'X-Forwarded-Proto': 'https',
|
|
||||||
},
|
|
||||||
keepAlive: true,
|
|
||||||
},
|
|
||||||
true, // lets make this streaming (keepAlive)
|
|
||||||
(proxyRequest) => {
|
|
||||||
originRequest.on('data', (data) => {
|
|
||||||
proxyRequest.write(data);
|
|
||||||
});
|
|
||||||
originRequest.on('end', () => {
|
|
||||||
proxyRequest.end();
|
|
||||||
});
|
|
||||||
originRequest.on('error', () => {
|
|
||||||
proxyRequest.end();
|
|
||||||
});
|
|
||||||
originRequest.on('close', () => {
|
|
||||||
proxyRequest.end();
|
|
||||||
});
|
|
||||||
originRequest.on('timeout', () => {
|
|
||||||
proxyRequest.end();
|
|
||||||
originRequest.destroy();
|
|
||||||
});
|
|
||||||
proxyRequest.on('error', () => {
|
|
||||||
endOriginReqRes();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
originResponse.statusCode = proxyResponse.statusCode;
|
|
||||||
console.log(proxyResponse.statusCode);
|
|
||||||
for (const defaultHeader of Object.keys(this.defaultHeaders)) {
|
|
||||||
originResponse.setHeader(defaultHeader, this.defaultHeaders[defaultHeader]);
|
|
||||||
}
|
|
||||||
for (const header of Object.keys(proxyResponse.headers)) {
|
|
||||||
originResponse.setHeader(header, proxyResponse.headers[header]);
|
|
||||||
}
|
|
||||||
proxyResponse.on('data', (data) => {
|
|
||||||
originResponse.write(data);
|
|
||||||
});
|
|
||||||
proxyResponse.on('end', () => {
|
|
||||||
originResponse.end();
|
|
||||||
});
|
|
||||||
proxyResponse.on('error', () => {
|
|
||||||
originResponse.destroy();
|
|
||||||
});
|
|
||||||
proxyResponse.on('close', () => {
|
|
||||||
originResponse.end();
|
|
||||||
});
|
|
||||||
proxyResponse.on('timeout', () => {
|
|
||||||
originResponse.end();
|
|
||||||
originResponse.destroy();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error while processing request:', error);
|
|
||||||
endOriginReqRes(502, 'Bad Gateway: Error processing the request');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enable websockets
|
|
||||||
const wsServer = new plugins.ws.WebSocketServer({ server: this.httpsServer });
|
|
||||||
|
|
||||||
// Set up the heartbeat interval
|
|
||||||
this.heartbeatInterval = setInterval(() => {
|
|
||||||
wsServer.clients.forEach((ws: plugins.wsDefault) => {
|
|
||||||
const wsIncoming = ws as WebSocketWithHeartbeat;
|
|
||||||
if (!wsIncoming.lastPong) {
|
|
||||||
wsIncoming.lastPong = Date.now();
|
|
||||||
}
|
|
||||||
if (Date.now() - wsIncoming.lastPong > 5 * 60 * 1000) {
|
|
||||||
console.log('Terminating websocket due to missing pong for 5 minutes.');
|
|
||||||
wsIncoming.terminate();
|
|
||||||
} else {
|
|
||||||
wsIncoming.ping();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 60000); // runs every 1 minute
|
|
||||||
|
|
||||||
wsServer.on(
|
|
||||||
'connection',
|
|
||||||
async (wsIncoming: WebSocketWithHeartbeat, reqArg: plugins.http.IncomingMessage) => {
|
|
||||||
console.log(
|
|
||||||
`wss proxy: got connection for wsc for https://${reqArg.headers.host}${reqArg.url}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
wsIncoming.lastPong = Date.now();
|
|
||||||
wsIncoming.on('pong', () => {
|
|
||||||
wsIncoming.lastPong = Date.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
let wsOutgoing: plugins.wsDefault;
|
|
||||||
|
|
||||||
const outGoingDeferred = plugins.smartpromise.defer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
wsOutgoing = new plugins.wsDefault(
|
|
||||||
`ws://${this.router.routeReq(reqArg).destinationIp}:${
|
|
||||||
this.router.routeReq(reqArg).destinationPort
|
|
||||||
}${reqArg.url}`,
|
|
||||||
);
|
|
||||||
console.log('wss proxy: initiated outgoing proxy');
|
|
||||||
wsOutgoing.on('open', async () => {
|
|
||||||
outGoingDeferred.resolve();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
wsIncoming.terminate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
wsIncoming.on('message', async (message, isBinary) => {
|
|
||||||
try {
|
|
||||||
await outGoingDeferred.promise;
|
|
||||||
wsOutgoing.send(message, { binary: isBinary });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending message to wsOutgoing:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wsOutgoing.on('message', async (message, isBinary) => {
|
|
||||||
try {
|
|
||||||
wsIncoming.send(message, { binary: isBinary });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending message to wsIncoming:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const terminateWsOutgoing = () => {
|
|
||||||
if (wsOutgoing) {
|
|
||||||
wsOutgoing.terminate();
|
|
||||||
console.log('terminated outgoing ws.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
wsIncoming.on('error', () => terminateWsOutgoing());
|
|
||||||
wsIncoming.on('close', () => terminateWsOutgoing());
|
|
||||||
|
|
||||||
const terminateWsIncoming = () => {
|
|
||||||
if (wsIncoming) {
|
|
||||||
wsIncoming.terminate();
|
|
||||||
console.log('terminated incoming ws.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
wsOutgoing.on('error', () => terminateWsIncoming());
|
|
||||||
wsOutgoing.on('close', () => terminateWsIncoming());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.httpsServer.keepAliveTimeout = 600 * 1000;
|
|
||||||
this.httpsServer.headersTimeout = 600 * 1000;
|
|
||||||
|
|
||||||
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
|
||||||
this.socketMap.add(connection);
|
|
||||||
console.log(`added connection. now ${this.socketMap.getArray().length} sockets connected.`);
|
|
||||||
const cleanupConnection = () => {
|
|
||||||
if (this.socketMap.checkForObject(connection)) {
|
|
||||||
this.socketMap.remove(connection);
|
|
||||||
console.log(`removed connection. ${this.socketMap.getArray().length} sockets remaining.`);
|
|
||||||
connection.destroy();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
connection.on('close', () => {
|
|
||||||
cleanupConnection();
|
|
||||||
});
|
|
||||||
connection.on('error', () => {
|
|
||||||
cleanupConnection();
|
|
||||||
});
|
|
||||||
connection.on('end', () => {
|
|
||||||
cleanupConnection();
|
|
||||||
});
|
|
||||||
connection.on('timeout', () => {
|
|
||||||
cleanupConnection();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.httpsServer.listen(this.options.port);
|
|
||||||
console.log(
|
|
||||||
`NetworkProxy -> OK: now listening for new connections on port ${this.options.port}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateProxyConfigs(proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[]) {
|
|
||||||
console.log(`got new proxy configs`);
|
|
||||||
this.proxyConfigs = proxyConfigsArg;
|
|
||||||
this.router.setNewProxyConfigs(proxyConfigsArg);
|
|
||||||
for (const hostCandidate of this.proxyConfigs) {
|
|
||||||
const existingHostNameConfig = this.alreadyAddedReverseConfigs[hostCandidate.hostName];
|
|
||||||
|
|
||||||
if (!existingHostNameConfig) {
|
|
||||||
this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
|
|
||||||
} else {
|
|
||||||
if (
|
|
||||||
existingHostNameConfig.publicKey === hostCandidate.publicKey &&
|
|
||||||
existingHostNameConfig.privateKey === hostCandidate.privateKey
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
this.alreadyAddedReverseConfigs[hostCandidate.hostName] = hostCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.httpsServer.addContext(hostCandidate.hostName, {
|
|
||||||
cert: hostCandidate.publicKey,
|
|
||||||
key: hostCandidate.privateKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async addDefaultHeaders(headersArg: { [key: string]: string }) {
|
|
||||||
for (const headerKey of Object.keys(headersArg)) {
|
|
||||||
this.defaultHeaders[headerKey] = headersArg[headerKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
const done = plugins.smartpromise.defer();
|
|
||||||
this.httpsServer.close(() => {
|
|
||||||
done.resolve();
|
|
||||||
});
|
|
||||||
await this.socketMap.forEach(async (socket) => {
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
await done.promise;
|
|
||||||
clearInterval(this.heartbeatInterval);
|
|
||||||
console.log('NetworkProxy -> OK: Server has been stopped and all connections closed.');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
import * as plugins from './smartproxy.plugins.js';
|
|
||||||
import * as net from 'net';
|
|
||||||
|
|
||||||
export class PortProxy {
|
|
||||||
netServer: plugins.net.Server;
|
|
||||||
fromPort: number;
|
|
||||||
toPort: number;
|
|
||||||
|
|
||||||
constructor(fromPortArg: number, toPortArg: number) {
|
|
||||||
this.fromPort = fromPortArg;
|
|
||||||
this.toPort = toPortArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
const cleanUpSockets = (from: plugins.net.Socket, to: plugins.net.Socket) => {
|
|
||||||
from.end();
|
|
||||||
to.end();
|
|
||||||
from.removeAllListeners();
|
|
||||||
to.removeAllListeners();
|
|
||||||
from.unpipe();
|
|
||||||
to.unpipe();
|
|
||||||
from.destroy();
|
|
||||||
to.destroy();
|
|
||||||
};
|
|
||||||
this.netServer = net
|
|
||||||
.createServer((from) => {
|
|
||||||
const to = net.createConnection({
|
|
||||||
host: 'localhost',
|
|
||||||
port: this.toPort,
|
|
||||||
});
|
|
||||||
from.setTimeout(120000);
|
|
||||||
from.pipe(to);
|
|
||||||
to.pipe(from);
|
|
||||||
from.on('error', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
to.on('error', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
from.on('close', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
to.on('close', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
from.on('timeout', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
to.on('timeout', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
from.on('end', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
to.on('end', () => {
|
|
||||||
cleanUpSockets(from, to);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.listen(this.fromPort);
|
|
||||||
console.log(`PortProxy -> OK: Now listening on port ${this.fromPort}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
const done = plugins.smartpromise.defer();
|
|
||||||
this.netServer.close(() => {
|
|
||||||
done.resolve();
|
|
||||||
});
|
|
||||||
await done.promise;
|
|
||||||
}
|
|
||||||
}
|
|
Reference in New Issue
Block a user