Compare commits

...

24 Commits

Author SHA1 Message Date
f1b810a4fa 3.16.7 2025-02-27 15:32:06 +00:00
96b5877c5f fix(PortProxy): Improved IP validation logic in PortProxy to ensure correct domain matching and fallback 2025-02-27 15:32:06 +00:00
6d627f67f7 3.16.6 2025-02-27 15:30:20 +00:00
9af968b8e7 fix(PortProxy): Optimize connection cleanup logic in PortProxy by removing unnecessary delays. 2025-02-27 15:30:20 +00:00
b3ba0c21e8 3.16.5 2025-02-27 15:05:38 +00:00
ef707a5870 fix(PortProxy): Improved connection cleanup process with added asynchronous delays 2025-02-27 15:05:38 +00:00
6ca14edb38 3.16.4 2025-02-27 14:23:44 +00:00
5a5686b6b9 fix(PortProxy): Fix and enhance port proxy handling 2025-02-27 14:23:44 +00:00
2080f419cb 3.16.3 2025-02-27 13:04:01 +00:00
659aae297b fix(PortProxy): Refactored PortProxy to support multiple listening ports and improved modularity. 2025-02-27 13:04:01 +00:00
fcd0f61b5c 3.16.2 2025-02-27 12:54:15 +00:00
7ee35a98e3 fix(PortProxy): Fix port-based routing logic in PortProxy 2025-02-27 12:54:14 +00:00
ea0f6d2270 3.16.1 2025-02-27 12:42:50 +00:00
621ad9e681 fix(core): Updated minor version numbers in dependencies for patch release. 2025-02-27 12:42:50 +00:00
7cea5773ee 3.16.0 2025-02-27 12:41:20 +00:00
a2cb56ba65 feat(PortProxy): Enhancements made to PortProxy settings and capabilities 2025-02-27 12:41:20 +00:00
408b793149 3.15.0 2025-02-27 12:25:48 +00:00
f6c3d2d3d0 feat(classes.portproxy): Add support for port range-based routing with enhanced IP and port validation. 2025-02-27 12:25:48 +00:00
422eb5ec40 3.14.2 2025-02-26 19:00:09 +00:00
45390c4389 fix(PortProxy): Fix cleanup timer reset for PortProxy 2025-02-26 19:00:09 +00:00
0f2e6d688c 3.14.1 2025-02-26 12:56:00 +00:00
3bd7b70c19 fix(PortProxy): Increased default maxConnectionLifetime for PortProxy to 600000 ms 2025-02-26 12:56:00 +00:00
07a82a09be 3.14.0 2025-02-26 10:29:21 +00:00
23253a2731 feat(PortProxy): Introduce max connection lifetime feature 2025-02-26 10:29:21 +00:00
5 changed files with 299 additions and 109 deletions

View File

@ -1,5 +1,80 @@
# Changelog # Changelog
## 2025-02-27 - 3.16.7 - fix(PortProxy)
Improved IP validation logic in PortProxy to ensure correct domain matching and fallback
- Refactored the setupConnection function inside PortProxy to enhance IP address validation.
- Domain-specific allowed IP preference is applied before default list lookup.
- Removed redundant condition checks to streamline connection rejection paths.
## 2025-02-27 - 3.16.6 - fix(PortProxy)
Optimize connection cleanup logic in PortProxy by removing unnecessary delays.
- Removed multiple await plugins.smartdelay.delayFor(0) calls.
- Improved performance by ensuring timely resource release during connection termination.
## 2025-02-27 - 3.16.5 - fix(PortProxy)
Improved connection cleanup process with added asynchronous delays
- Connection cleanup now includes asynchronous delays for reliable order of operations.
## 2025-02-27 - 3.16.4 - fix(PortProxy)
Fix and enhance port proxy handling
- Ensure that all created proxy servers are correctly checked for listening state.
- Corrected the handling of ports and domain configurations within port proxy setups.
- Expanded test coverage for handling multiple concurrent and chained proxy connections.
## 2025-02-27 - 3.16.3 - fix(PortProxy)
Refactored PortProxy to support multiple listening ports and improved modularity.
- Updated PortProxy to allow multiple listening ports with flexible configuration.
- Moved helper functions for IP and port range checks outside the class for cleaner code structure.
## 2025-02-27 - 3.16.2 - fix(PortProxy)
Fix port-based routing logic in PortProxy
- Optimized the handling and checking of local ports in the global port range.
- Fixed the logic for rejecting or accepting connections based on predefined port ranges.
- Improved handling of the default and specific domain configurations during port-based connections.
## 2025-02-27 - 3.16.1 - fix(core)
Updated minor version numbers in dependencies for patch release.
- No specific file changes detected.
- Dependencies versioning adjusted for stability.
## 2025-02-27 - 3.16.0 - feat(PortProxy)
Enhancements made to PortProxy settings and capabilities
- Added 'forwardAllGlobalRanges' and 'targetIP' to IPortProxySettings.
- Improved PortProxy to forward connections based on domain-specific configurations.
- Added comprehensive handling for global port-range based connection forwarding.
- Enabled forwarding of all connections on global port ranges directly to global target IP.
## 2025-02-27 - 3.15.0 - feat(classes.portproxy)
Add support for port range-based routing with enhanced IP and port validation.
- Introduced globalPortRanges in IPortProxySettings for routing based on port ranges.
- Improved connection handling with port range and domain configuration validations.
- Updated connection logging to include the local port information.
## 2025-02-26 - 3.14.2 - fix(PortProxy)
Fix cleanup timer reset for PortProxy
- Resolved an issue where the cleanup timer in the PortProxy class did not reset correctly if both incoming and outgoing data events were triggered without clearing flags.
## 2025-02-26 - 3.14.1 - fix(PortProxy)
Increased default maxConnectionLifetime for PortProxy to 600000 ms
- Updated PortProxy settings to extend default maxConnectionLifetime to 10 minutes.
## 2025-02-26 - 3.14.0 - feat(PortProxy)
Introduce max connection lifetime feature
- Added an optional maxConnectionLifetime setting for PortProxy.
- Forces cleanup of long-lived connections based on inactivity or lifetime limit.
## 2025-02-25 - 3.13.0 - feat(core) ## 2025-02-25 - 3.13.0 - feat(core)
Add support for tagging iptables rules with comments and cleaning them up on process exit Add support for tagging iptables rules with comments and cleaning them up on process exit

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "3.13.0", "version": "3.16.7",
"private": false, "private": false,
"description": "A robust and versatile proxy package designed to handle high workloads, offering features like SSL redirection, port proxying, WebSocket support, and customizable routing and authentication.", "description": "A robust and versatile proxy package designed to handle high workloads, offering features like SSL redirection, port proxying, WebSocket support, and customizable routing and authentication.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@ -16,12 +16,10 @@ function createTestServer(port: number): Promise<net.Server> {
// Echo the received data back // Echo the received data back
socket.write(`Echo: ${data.toString()}`); socket.write(`Echo: ${data.toString()}`);
}); });
socket.on('error', (error) => { socket.on('error', (error) => {
console.error('[Test Server] Socket error:', error); console.error('[Test Server] Socket error:', error);
}); });
}); });
server.listen(port, () => { server.listen(port, () => {
console.log(`[Test Server] Listening on port ${port}`); console.log(`[Test Server] Listening on port ${port}`);
resolve(server); resolve(server);
@ -39,16 +37,13 @@ function createTestClient(port: number, data: string): Promise<string> {
console.log('[Test Client] Connected to server'); console.log('[Test Client] Connected to server');
client.write(data); client.write(data);
}); });
client.on('data', (chunk) => { client.on('data', (chunk) => {
response += chunk.toString(); response += chunk.toString();
client.end(); client.end();
}); });
client.on('end', () => { client.on('end', () => {
resolve(response); resolve(response);
}); });
client.on('error', (error) => { client.on('error', (error) => {
reject(error); reject(error);
}); });
@ -61,16 +56,18 @@ tap.test('setup port proxy test environment', async () => {
portProxy = new PortProxy({ portProxy = new PortProxy({
fromPort: PROXY_PORT, fromPort: PROXY_PORT,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
toHost: 'localhost', targetIP: 'localhost',
domains: [], domains: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'] defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
}); });
}); });
tap.test('should start port proxy', async () => { tap.test('should start port proxy', async () => {
await portProxy.start(); await portProxy.start();
expect(portProxy.netServer.listening).toBeTrue(); // Since netServers is private, we cast to any to verify that all created servers are listening.
expect((portProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue();
}); });
tap.test('should forward TCP connections and data to localhost', async () => { tap.test('should forward TCP connections and data to localhost', async () => {
@ -79,14 +76,15 @@ tap.test('should forward TCP connections and data to localhost', async () => {
}); });
tap.test('should forward TCP connections to custom host', async () => { tap.test('should forward TCP connections to custom host', async () => {
// Create a new proxy instance with a custom host // Create a new proxy instance with a custom host (targetIP)
const customHostProxy = new PortProxy({ const customHostProxy = new PortProxy({
fromPort: PROXY_PORT + 1, fromPort: PROXY_PORT + 1,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
toHost: '127.0.0.1', targetIP: '127.0.0.1',
domains: [], domains: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'] defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
}); });
await customHostProxy.start(); await customHostProxy.start();
@ -103,8 +101,8 @@ tap.test('should forward connections based on domain-specific target IP', async
// Create a proxy with domain-specific target IPs // Create a proxy with domain-specific target IPs
const domainProxy = new PortProxy({ const domainProxy = new PortProxy({
fromPort: PROXY_PORT + 2, fromPort: PROXY_PORT + 2,
toPort: TEST_SERVER_PORT, // default port toPort: TEST_SERVER_PORT, // default port (for non-port-range handling)
toHost: 'localhost', // default host targetIP: 'localhost', // default target IP
domains: [{ domains: [{
domain: 'domain1.test', domain: 'domain1.test',
allowedIPs: ['127.0.0.1'], allowedIPs: ['127.0.0.1'],
@ -114,24 +112,26 @@ tap.test('should forward connections based on domain-specific target IP', async
allowedIPs: ['127.0.0.1'], allowedIPs: ['127.0.0.1'],
targetIP: 'localhost' targetIP: 'localhost'
}], }],
sniEnabled: false, // We'll test without SNI first since this is a TCP proxy test sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'] defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
}); });
await domainProxy.start(); await domainProxy.start();
// Test default connection (should use default host) // Test default connection (should use default targetIP)
const response1 = await createTestClient(PROXY_PORT + 2, TEST_DATA); const response1 = await createTestClient(PROXY_PORT + 2, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`); expect(response1).toEqual(`Echo: ${TEST_DATA}`);
// Create another proxy with different default host // Create another proxy with a different default targetIP
const domainProxy2 = new PortProxy({ const domainProxy2 = new PortProxy({
fromPort: PROXY_PORT + 3, fromPort: PROXY_PORT + 3,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
toHost: '127.0.0.1', targetIP: '127.0.0.1',
domains: [], domains: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'] defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
}); });
await domainProxy2.start(); await domainProxy2.start();
@ -158,7 +158,6 @@ tap.test('should handle multiple concurrent connections', async () => {
tap.test('should handle connection timeouts', async () => { tap.test('should handle connection timeouts', async () => {
const client = new net.Socket(); const client = new net.Socket();
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
client.connect(PROXY_PORT, 'localhost', () => { client.connect(PROXY_PORT, 'localhost', () => {
// Don't send any data, just wait for timeout // Don't send any data, just wait for timeout
@ -171,28 +170,30 @@ tap.test('should handle connection timeouts', async () => {
tap.test('should stop port proxy', async () => { tap.test('should stop port proxy', async () => {
await portProxy.stop(); await portProxy.stop();
expect(portProxy.netServer.listening).toBeFalse(); expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
}); });
// Cleanup // Cleanup chained proxies tests
tap.test('should support optional source IP preservation in chained proxies', async () => { tap.test('should support optional source IP preservation in chained proxies', async () => {
// Test 1: Without IP preservation (default behavior) // Test 1: Without IP preservation (default behavior)
const firstProxyDefault = new PortProxy({ const firstProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 4, fromPort: PROXY_PORT + 4,
toPort: PROXY_PORT + 5, toPort: PROXY_PORT + 5,
toHost: 'localhost', targetIP: 'localhost',
domains: [], domains: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'] defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
}); });
const secondProxyDefault = new PortProxy({ const secondProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 5, fromPort: PROXY_PORT + 5,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
toHost: 'localhost', targetIP: 'localhost',
domains: [], domains: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'] defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
}); });
await secondProxyDefault.start(); await secondProxyDefault.start();
@ -209,21 +210,23 @@ tap.test('should support optional source IP preservation in chained proxies', as
const firstProxyPreserved = new PortProxy({ const firstProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 6, fromPort: PROXY_PORT + 6,
toPort: PROXY_PORT + 7, toPort: PROXY_PORT + 7,
toHost: 'localhost', targetIP: 'localhost',
domains: [], domains: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'], defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true preserveSourceIP: true,
globalPortRanges: []
}); });
const secondProxyPreserved = new PortProxy({ const secondProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 7, fromPort: PROXY_PORT + 7,
toPort: TEST_SERVER_PORT, toPort: TEST_SERVER_PORT,
toHost: 'localhost', targetIP: 'localhost',
domains: [], domains: [],
sniEnabled: false, sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'], defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true preserveSourceIP: true,
globalPortRanges: []
}); });
await secondProxyPreserved.start(); await secondProxyPreserved.start();
@ -245,7 +248,8 @@ process.on('exit', () => {
if (testServer) { if (testServer) {
testServer.close(); testServer.close();
} }
if (portProxy && portProxy.netServer) { // Use a cast to access the private property for cleanup.
if (portProxy && (portProxy as any).netServers) {
portProxy.stop(); portProxy.stop();
} }
}); });

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '3.13.0', version: '3.16.7',
description: 'A robust and versatile proxy package designed to handle high workloads, offering features like SSL redirection, port proxying, WebSocket support, and customizable routing and authentication.' description: 'A robust and versatile proxy package designed to handle high workloads, offering features like SSL redirection, port proxying, WebSocket support, and customizable routing and authentication.'
} }

View File

@ -1,19 +1,25 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
/** Domain configuration with perdomain allowed port ranges */
export interface IDomainConfig { export interface IDomainConfig {
domain: string; // Glob pattern for domain domain: string; // Glob pattern for domain
allowedIPs: string[]; // Glob patterns for allowed IPs allowedIPs: string[]; // Glob patterns for allowed IPs
targetIP?: string; // Optional target IP for this domain targetIP?: string; // Optional target IP for this domain
portRanges?: Array<{ from: number; to: number }>; // Optional domain-specific allowed port ranges
} }
/** Port proxy settings including global allowed port ranges */
export interface IPortProxySettings extends plugins.tls.TlsOptions { export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number; fromPort: number;
toPort: number; toPort: number;
toHost?: string; // Target host to proxy to, defaults to 'localhost' targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domains: IDomainConfig[]; domains: IDomainConfig[];
sniEnabled?: boolean; sniEnabled?: boolean;
defaultAllowedIPs?: string[]; defaultAllowedIPs?: string[];
preserveSourceIP?: boolean; preserveSourceIP?: boolean;
maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
} }
/** /**
@ -85,10 +91,11 @@ interface IConnectionRecord {
incomingStartTime: number; incomingStartTime: number;
outgoingStartTime?: number; outgoingStartTime?: number;
connectionClosed: boolean; connectionClosed: boolean;
cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity
} }
export class PortProxy { export class PortProxy {
netServer: plugins.net.Server; private netServers: plugins.net.Server[] = [];
settings: IPortProxySettings; settings: IPortProxySettings;
// Unified record tracking each connection pair. // Unified record tracking each connection pair.
private connectionRecords: Set<IConnectionRecord> = new Set(); private connectionRecords: Set<IConnectionRecord> = new Set();
@ -102,10 +109,11 @@ export class PortProxy {
outgoing: {}, outgoing: {},
}; };
constructor(settings: IPortProxySettings) { constructor(settingsArg: IPortProxySettings) {
this.settings = { this.settings = {
...settings, ...settingsArg,
toHost: settings.toHost || 'localhost', targetIP: settingsArg.targetIP || 'localhost',
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
}; };
} }
@ -114,39 +122,10 @@ export class PortProxy {
} }
public async start() { public async start() {
// Helper to forcefully destroy sockets. // Define a unified connection handler for all listening ports.
const cleanUpSockets = (socketA: plugins.net.Socket, socketB?: plugins.net.Socket) => { const connectionHandler = (socket: 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 remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort; // The port on which this connection was accepted.
const connectionRecord: IConnectionRecord = { const connectionRecord: IConnectionRecord = {
incoming: socket, incoming: socket,
outgoing: null, outgoing: null,
@ -154,17 +133,21 @@ export class PortProxy {
connectionClosed: false, connectionClosed: false,
}; };
this.connectionRecords.add(connectionRecord); this.connectionRecords.add(connectionRecord);
console.log(`New connection from ${remoteIP}. Active connections: ${this.connectionRecords.size}`); console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
let initialDataReceived = false; let initialDataReceived = false;
let incomingTerminationReason: string | null = null; let incomingTerminationReason: string | null = null;
let outgoingTerminationReason: string | null = null; let outgoingTerminationReason: string | null = null;
// Ensure cleanup happens only once for the entire connection record. // Ensure cleanup happens only once for the entire connection record.
const cleanupOnce = () => { const cleanupOnce = async () => {
if (!connectionRecord.connectionClosed) { if (!connectionRecord.connectionClosed) {
connectionRecord.connectionClosed = true; connectionRecord.connectionClosed = true;
cleanUpSockets(connectionRecord.incoming, connectionRecord.outgoing || undefined); if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
if (!socket.destroyed) socket.destroy();
if (connectionRecord.outgoing && !connectionRecord.outgoing.destroyed) connectionRecord.outgoing.destroy();
this.connectionRecords.delete(connectionRecord); this.connectionRecords.delete(connectionRecord);
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`); console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
} }
@ -219,28 +202,34 @@ export class PortProxy {
cleanupOnce(); cleanupOnce();
}; };
const setupConnection = (serverName: string, initialChunk?: Buffer) => { /**
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs); * Sets up the connection to the target host.
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
* @param initialChunk - Optional initial data chunk.
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
* @param overridePort - If provided, use this port for the outgoing connection (typically the same as the incoming port).
*/
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
? forcedDomain
: (serverName ? this.settings.domains.find(config => plugins.minimatch(serverName, config.domain)) : undefined);
if (!defaultAllowed && serverName) { // New check: if a matching domain config exists, use its allowedIPs in preference.
const domainConfig = findMatchingDomain(serverName); if (domainConfig) {
if (!domainConfig) {
return rejectIncomingConnection('rejected', `Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`);
}
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) { if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`); return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domain}`);
} }
} else if (!defaultAllowed && !serverName) { } else if (this.settings.defaultAllowedIPs) {
return rejectIncomingConnection('rejected', `Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`); // Fallback to default allowed IPs if no domain config is found.
} else if (defaultAllowed && !serverName) { if (!isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`); return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
} }
}
const domainConfig = serverName ? findMatchingDomain(serverName) : undefined; const targetHost = domainConfig?.targetIP || this.settings.targetIP!;
const targetHost = domainConfig?.targetIP || this.settings.toHost!;
const connectionOptions: plugins.net.NetConnectOpts = { const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost, host: targetHost,
port: this.settings.toPort, port: overridePort !== undefined ? overridePort : this.settings.toPort,
}; };
if (this.settings.preserveSourceIP) { if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = remoteIP.replace('::ffff:', ''); connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
@ -251,8 +240,8 @@ export class PortProxy {
connectionRecord.outgoingStartTime = Date.now(); connectionRecord.outgoingStartTime = Date.now();
console.log( console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` + `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : ''}` `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domain})` : ''}`
); );
if (initialChunk) { if (initialChunk) {
@ -262,6 +251,7 @@ export class PortProxy {
socket.pipe(targetSocket); socket.pipe(targetSocket);
targetSocket.pipe(socket); targetSocket.pipe(socket);
// Attach error and close handlers.
socket.on('error', handleError('incoming')); socket.on('error', handleError('incoming'));
targetSocket.on('error', handleError('outgoing')); targetSocket.on('error', handleError('outgoing'));
socket.on('close', handleClose('incoming')); socket.on('close', handleClose('incoming'));
@ -284,8 +274,82 @@ export class PortProxy {
}); });
socket.on('end', handleClose('incoming')); socket.on('end', handleClose('incoming'));
targetSocket.on('end', handleClose('outgoing')); targetSocket.on('end', handleClose('outgoing'));
// Initialize a cleanup timer for max connection lifetime.
if (this.settings.maxConnectionLifetime) {
let incomingActive = false;
let outgoingActive = false;
const resetCleanupTimer = () => {
if (this.settings.maxConnectionLifetime) {
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
connectionRecord.cleanupTimer = setTimeout(() => {
console.log(`Connection from ${remoteIP} exceeded max lifetime with inactivity (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
cleanupOnce();
}, this.settings.maxConnectionLifetime);
}
}; };
resetCleanupTimer();
socket.on('data', () => {
incomingActive = true;
if (incomingActive && outgoingActive) {
resetCleanupTimer();
incomingActive = false;
outgoingActive = false;
}
});
targetSocket.on('data', () => {
outgoingActive = true;
if (incomingActive && outgoingActive) {
resetCleanupTimer();
incomingActive = false;
outgoingActive = false;
}
});
}
};
// --- PORT RANGE-BASED HANDLING ---
// If the local port is one of the globally listened ports, we may have port-based rules.
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
// If forwardAllGlobalRanges is enabled, always forward using the global targetIP.
if (this.settings.forwardAllGlobalRanges) {
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
socket.end();
return;
}
console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
setupConnection('', undefined, {
domain: 'global',
allowedIPs: this.settings.defaultAllowedIPs || [],
targetIP: this.settings.targetIP,
portRanges: []
}, localPort);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.settings.domains.find(
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
);
if (forcedDomain) {
if (!isAllowed(remoteIP, forcedDomain.allowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domain} on port ${localPort}.`);
socket.end();
return;
}
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domain}.`);
setupConnection('', undefined, forcedDomain, localPort);
return;
}
// Fall through to SNI/default handling if no forced domain config is found.
}
}
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) { if (this.settings.sniEnabled) {
socket.setTimeout(5000, () => { socket.setTimeout(5000, () => {
console.log(`Initial data timeout for ${remoteIP}`); console.log(`Initial data timeout for ${remoteIP}`);
@ -307,18 +371,38 @@ export class PortProxy {
} }
setupConnection(''); 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. // --- SETUP LISTENERS ---
// Determine which ports to listen on.
const listeningPorts = new Set<number>();
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
// Listen on every port defined by the global ranges.
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
// Also ensure the default fromPort is listened to if it isnt already in the ranges.
listeningPorts.add(this.settings.fromPort);
} else {
listeningPorts.add(this.settings.fromPort);
}
// Create a server for each port.
for (const port of listeningPorts) {
const server = plugins.net
.createServer(connectionHandler)
.on('error', (err: Error) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
console.log(`PortProxy -> OK: Now listening on port ${port}${this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''}`);
});
this.netServers.push(server);
}
// Log active connection count and longest running durations every 10 seconds.
this.connectionLogger = setInterval(() => { this.connectionLogger = setInterval(() => {
const now = Date.now(); const now = Date.now();
let maxIncoming = 0; let maxIncoming = 0;
@ -339,14 +423,41 @@ export class PortProxy {
} }
public async stop() { public async stop() {
const done = plugins.smartpromise.defer(); // Close all servers.
this.netServer.close(() => { const closePromises: Promise<void>[] = this.netServers.map(
done.resolve(); server =>
}); new Promise<void>((resolve) => {
server.close(() => resolve());
})
);
if (this.connectionLogger) { if (this.connectionLogger) {
clearInterval(this.connectionLogger); clearInterval(this.connectionLogger);
this.connectionLogger = null; this.connectionLogger = null;
} }
await done.promise; await Promise.all(closePromises);
} }
} }
// Helper: Check if a port falls within any of the given port ranges.
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some(range => port >= range.from && port <= range.to);
};
// Helper: Check if a given IP matches any of the glob patterns.
const isAllowed = (ip: string, patterns: string[]): boolean => {
const normalizeIP = (ip: string): string[] => {
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const normalizedIPVariants = normalizeIP(ip);
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some(ipVariant =>
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
);
};