Compare commits
34 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
109
changelog.md
109
changelog.md
@ -1,5 +1,114 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "3.5.0",
|
||||
"version": "3.10.4",
|
||||
"private": false,
|
||||
"description": "a proxy for handling high workloads of proxying",
|
||||
"main": "dist_ts/index.js",
|
||||
@ -29,10 +29,11 @@
|
||||
"@push.rocks/smartrequest": "^2.0.23",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@tsclass/tsclass": "^4.4.0",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/ws": "^8.5.14",
|
||||
"ws": "^8.18.0",
|
||||
"minimatch": "^9.0.3",
|
||||
"@types/minimatch": "^5.1.2"
|
||||
"pretty-ms": "^9.2.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -35,6 +35,9 @@ importers:
|
||||
minimatch:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.5
|
||||
pretty-ms:
|
||||
specifier: ^9.2.0
|
||||
version: 9.2.0
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
|
@ -175,6 +175,68 @@ tap.test('should stop port proxy', async () => {
|
||||
});
|
||||
|
||||
// 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()));
|
||||
});
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '3.5.0',
|
||||
version: '3.10.4',
|
||||
description: 'a proxy for handling high workloads of proxying'
|
||||
}
|
||||
|
@ -22,8 +22,9 @@ import * as smartstring from '@push.rocks/smartstring';
|
||||
export { lik, smartdelay, smartrequest, smartpromise, smartstring };
|
||||
|
||||
// third party scope
|
||||
import prettyMs from 'pretty-ms';
|
||||
import * as ws from 'ws';
|
||||
import wsDefault from 'ws';
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
export { wsDefault, ws, minimatch };
|
||||
export { prettyMs, ws, wsDefault, minimatch };
|
@ -1,4 +1,4 @@
|
||||
import * as plugins from './smartproxy.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { ProxyRouter } from './smartproxy.classes.router.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as plugins from './smartproxy.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export class ProxyRouter {
|
||||
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 {
|
||||
httpServer: plugins.http.Server;
|
||||
|
@ -1,173 +1,341 @@
|
||||
import * as plugins from './smartproxy.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
|
||||
export interface DomainConfig {
|
||||
domain: string; // glob pattern for domain
|
||||
allowedIPs: string[]; // glob patterns for IPs allowed to access this domain
|
||||
targetIP?: string; // Optional target IP for this domain
|
||||
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 ProxySettings extends plugins.tls.TlsOptions {
|
||||
// Port configuration
|
||||
export interface IProxySettings extends plugins.tls.TlsOptions {
|
||||
fromPort: number;
|
||||
toPort: number;
|
||||
toHost?: string; // Target host to proxy to, defaults to 'localhost'
|
||||
|
||||
// Domain and security settings
|
||||
domains: DomainConfig[];
|
||||
toHost?: string; // Target host to proxy to, defaults to 'localhost'
|
||||
domains: IDomainConfig[];
|
||||
sniEnabled?: boolean;
|
||||
defaultAllowedIPs?: string[]; // Optional default IP patterns if no matching domain found
|
||||
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 | plugins.tls.Server;
|
||||
settings: ProxySettings;
|
||||
netServer: plugins.net.Server;
|
||||
settings: IProxySettings;
|
||||
// Unified record tracking each connection pair.
|
||||
private connectionRecords: Set<IConnectionRecord> = new Set();
|
||||
private connectionLogger: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(settings: ProxySettings) {
|
||||
private terminationStats: {
|
||||
incoming: Record<string, number>;
|
||||
outgoing: Record<string, number>;
|
||||
} = {
|
||||
incoming: {},
|
||||
outgoing: {},
|
||||
};
|
||||
|
||||
constructor(settings: IProxySettings) {
|
||||
this.settings = {
|
||||
...settings,
|
||||
toHost: settings.toHost || 'localhost'
|
||||
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() {
|
||||
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();
|
||||
// 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[] => {
|
||||
// Handle IPv4-mapped IPv6 addresses
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
const ipv4 = ip.slice(7); // Remove '::ffff:' prefix
|
||||
const ipv4 = ip.slice(7);
|
||||
return [ip, ipv4];
|
||||
}
|
||||
// Handle IPv4 addresses by adding IPv4-mapped IPv6 variant
|
||||
if (ip.match(/^\d{1,3}(\.\d{1,3}){3}$/)) {
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
||||
return [ip, `::ffff:${ip}`];
|
||||
}
|
||||
return [ip];
|
||||
};
|
||||
|
||||
const isAllowed = (value: string, patterns: string[]): boolean => {
|
||||
// Expand patterns to include both IPv4 and IPv6 variants
|
||||
// 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);
|
||||
// Check if any variant of the IP matches any expanded pattern
|
||||
return normalizeIP(value).some(ip =>
|
||||
expandedPatterns.some(pattern => plugins.minimatch(ip, pattern))
|
||||
return normalizedIPVariants.some(ipVariant =>
|
||||
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
|
||||
);
|
||||
};
|
||||
|
||||
const findMatchingDomain = (serverName: string): DomainConfig | undefined => {
|
||||
return this.settings.domains.find(config => plugins.minimatch(serverName, config.domain));
|
||||
};
|
||||
// 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));
|
||||
|
||||
const server = this.settings.sniEnabled
|
||||
? plugins.tls.createServer({
|
||||
...this.settings,
|
||||
SNICallback: (serverName: string, cb: (err: Error | null, ctx?: plugins.tls.SecureContext) => void) => {
|
||||
console.log(`SNI request for domain: ${serverName}`);
|
||||
const domainConfig = findMatchingDomain(serverName);
|
||||
if (!domainConfig) {
|
||||
// Always allow SNI for default IPs, even if domain doesn't match
|
||||
console.log(`SNI domain ${serverName} not found, will check IP during connection`);
|
||||
}
|
||||
// Create context with the provided TLS settings
|
||||
const ctx = plugins.tls.createSecureContext(this.settings);
|
||||
cb(null, ctx);
|
||||
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}`);
|
||||
}
|
||||
})
|
||||
: plugins.net.createServer();
|
||||
|
||||
const handleConnection = (from: plugins.net.Socket | plugins.tls.TLSSocket) => {
|
||||
const remoteIP = from.remoteAddress || '';
|
||||
let serverName = '';
|
||||
|
||||
// First check if this IP is in the default allowed list
|
||||
const isDefaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
|
||||
|
||||
if (this.settings.sniEnabled && from instanceof plugins.tls.TLSSocket) {
|
||||
serverName = (from as any).servername || '';
|
||||
console.log(`TLS Connection from ${remoteIP} for domain: ${serverName}`);
|
||||
}
|
||||
|
||||
// If IP is in defaultAllowedIPs, allow the connection regardless of SNI
|
||||
if (isDefaultAllowed) {
|
||||
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`);
|
||||
} else if (this.settings.sniEnabled && serverName) {
|
||||
// For SNI connections that aren't in default list, check domain-specific rules
|
||||
const domainConfig = findMatchingDomain(serverName);
|
||||
if (!domainConfig) {
|
||||
console.log(`Connection rejected: No matching domain config for ${serverName} from IP ${remoteIP}`);
|
||||
from.end();
|
||||
return;
|
||||
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`);
|
||||
}
|
||||
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
|
||||
console.log(`Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`);
|
||||
from.end();
|
||||
return;
|
||||
|
||||
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 {
|
||||
// Non-SNI connection and not in default list
|
||||
console.log(`Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
|
||||
from.end();
|
||||
return;
|
||||
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('');
|
||||
}
|
||||
|
||||
// Determine target host - use domain-specific targetIP if available
|
||||
const domainConfig = serverName ? findMatchingDomain(serverName) : undefined;
|
||||
const targetHost = domainConfig?.targetIP || this.settings.toHost!;
|
||||
|
||||
const to = plugins.net.createConnection({
|
||||
host: targetHost,
|
||||
port: this.settings.toPort,
|
||||
});
|
||||
console.log(`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}${serverName ? ` (SNI: ${serverName})` : ''}`);
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
this.netServer = server
|
||||
.on('connection', handleConnection)
|
||||
.on('secureConnection', handleConnection)
|
||||
.on('tlsClientError', (err, tlsSocket) => {
|
||||
console.log(`TLS Client Error: ${err.message}`);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
})
|
||||
.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 enabled)' : ''}`);
|
||||
.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() {
|
||||
@ -175,6 +343,10 @@ export class PortProxy {
|
||||
this.netServer.close(() => {
|
||||
done.resolve();
|
||||
});
|
||||
if (this.connectionLogger) {
|
||||
clearInterval(this.connectionLogger);
|
||||
this.connectionLogger = null;
|
||||
}
|
||||
await done.promise;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user