Compare commits

...

10 Commits

4 changed files with 154 additions and 18 deletions

View File

@ -1,5 +1,36 @@
# Changelog # Changelog
## 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.0",
"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

@ -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.0',
description: 'A robust and versatile proxy package designed to handle high workloads, offering features like SSL redirection, port proxying, WebSocket support, and customizable routing and authentication.' 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,6 +91,7 @@ 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 {
@ -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,
}; };
} }
@ -141,12 +149,18 @@ export class PortProxy {
); );
}; };
// Find a matching domain config based on the SNI. // Check if a port falls within any of the given port ranges.
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some(range => port >= range.from && port <= range.to);
};
// Find a matching domain config based on SNI (fallback when port ranges arent used)
const findMatchingDomain = (serverName: string): IDomainConfig | undefined => const findMatchingDomain = (serverName: string): IDomainConfig | undefined =>
this.settings.domains.find(config => plugins.minimatch(serverName, config.domain)); this.settings.domains.find(config => plugins.minimatch(serverName, config.domain));
this.netServer = plugins.net.createServer((socket: plugins.net.Socket) => { 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,7 +168,7 @@ 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;
@ -164,6 +178,9 @@ export class PortProxy {
const cleanupOnce = () => { const cleanupOnce = () => {
if (!connectionRecord.connectionClosed) { if (!connectionRecord.connectionClosed) {
connectionRecord.connectionClosed = true; connectionRecord.connectionClosed = true;
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
cleanUpSockets(connectionRecord.incoming, connectionRecord.outgoing || undefined); cleanUpSockets(connectionRecord.incoming, connectionRecord.outgoing || undefined);
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,25 +236,28 @@ export class PortProxy {
cleanupOnce(); cleanupOnce();
}; };
const setupConnection = (serverName: string, initialChunk?: Buffer) => { /**
* 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).
*/
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig) => {
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain ? forcedDomain : (serverName ? findMatchingDomain(serverName) : undefined);
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs); const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
if (!defaultAllowed && serverName) { if (!defaultAllowed && serverName && !forcedDomain) {
const domainConfig = findMatchingDomain(serverName);
if (!domainConfig) { if (!domainConfig) {
return rejectIncomingConnection('rejected', `Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`); 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 ${serverName}`);
} }
} else if (!defaultAllowed && !serverName) {
return rejectIncomingConnection('rejected', `Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`);
} else if (defaultAllowed && !serverName) { } else if (defaultAllowed && !serverName) {
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`); console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`);
} }
const targetHost = domainConfig?.targetIP || this.settings.targetIP!;
const domainConfig = serverName ? findMatchingDomain(serverName) : undefined;
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: this.settings.toPort,
@ -252,7 +272,7 @@ export class PortProxy {
console.log( console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` + `Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` +
`${serverName ? ` (SNI: ${serverName})` : ''}` `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domain})` : ''}`
); );
if (initialChunk) { if (initialChunk) {
@ -262,6 +282,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 +305,92 @@ 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 global port ranges are defined, enforce port-based routing and ignore SNI.
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
if (!isPortInRanges(localPort, this.settings.globalPortRanges)) {
console.log(`Connection from ${remoteIP} rejected: port ${localPort} is not in global allowed ranges.`);
socket.destroy();
return;
}
if (this.settings.forwardAllGlobalRanges) {
// Forward connection to the global targetIP regardless of domain config.
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
socket.end();
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: []
});
return;
} else {
// Find a matching domain config based on the incoming local port.
const forcedDomain = this.settings.domains.find(
domain => domain.portRanges && domain.portRanges.length > 0 && isPortInRanges(localPort, domain.portRanges)
);
if (!forcedDomain) {
console.log(`Connection from ${remoteIP} rejected: port ${localPort} not configured in any domain's portRanges.`);
socket.destroy();
return;
}
// Check allowed IPs for the forced domain.
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
if (!defaultAllowed && !isAllowed(remoteIP, forcedDomain.allowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domain} on port ${localPort}.`);
socket.end();
return;
}
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domain}.`);
// Proceed immediately using the forced domain; ignore SNI.
setupConnection('', undefined, forcedDomain);
return;
}
}
// --- FALLBACK: SNI-BASED HANDLING (if no global port ranges are defined) ---
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}`);
@ -318,7 +423,7 @@ export class PortProxy {
); );
}); });
// Every 10 seconds log active connection count and longest running durations. // 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;