feat(classes.portproxy): Add support for port range-based routing with enhanced IP and port validation.

This commit is contained in:
Philipp Kunz 2025-02-27 12:25:48 +00:00
parent 422eb5ec40
commit f6c3d2d3d0
3 changed files with 65 additions and 16 deletions

View File

@ -1,5 +1,12 @@
# Changelog
## 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

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.14.2',
version: '3.15.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.'
}

View File

@ -1,11 +1,14 @@
import * as plugins from './plugins.js';
/** Domain configuration with perdomain allowed port ranges */
export interface IDomainConfig {
domain: string; // Glob pattern for domain
allowedIPs: string[]; // Glob patterns for allowed IPs
targetIP?: string; // Optional target IP for this domain
portRanges: Array<{ from: number; to: number }>; // Domain-specific allowed port ranges
}
/** Port proxy settings including global allowed port ranges */
export interface IPortProxySettings extends plugins.tls.TlsOptions {
fromPort: number;
toPort: number;
@ -14,7 +17,8 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
preserveSourceIP?: boolean;
maxConnectionLifetime?: number; // New option (in milliseconds) to force cleanup of long-lived connections
maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
}
/**
@ -144,12 +148,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 =>
this.settings.domains.find(config => plugins.minimatch(serverName, config.domain));
this.netServer = plugins.net.createServer((socket: plugins.net.Socket) => {
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort; // The port on which this connection was accepted.
const connectionRecord: IConnectionRecord = {
incoming: socket,
outgoing: null,
@ -157,7 +167,7 @@ export class PortProxy {
connectionClosed: false,
};
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 incomingTerminationReason: string | null = null;
@ -225,24 +235,27 @@ export class PortProxy {
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);
if (!defaultAllowed && serverName) {
const domainConfig = findMatchingDomain(serverName);
if (!defaultAllowed && serverName && !forcedDomain) {
if (!domainConfig) {
return rejectIncomingConnection('rejected', `Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`);
}
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`);
}
} else if (!defaultAllowed && !serverName) {
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,
@ -258,7 +271,7 @@ export class PortProxy {
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` +
`${serverName ? ` (SNI: ${serverName})` : ''}`
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domain})` : ''}`
);
if (initialChunk) {
@ -292,7 +305,7 @@ export class PortProxy {
socket.on('end', handleClose('incoming'));
targetSocket.on('end', handleClose('outgoing'));
// If maxConnectionLifetime is set, initialize a cleanup timer that will be reset on data flow.
// Initialize a cleanup timer for max connection lifetime.
if (this.settings.maxConnectionLifetime) {
let incomingActive = false;
let outgoingActive = false;
@ -308,10 +321,8 @@ export class PortProxy {
}
};
// Start the cleanup timer.
resetCleanupTimer();
// Listen for data events on both sides and reset the timer when both are active.
socket.on('data', () => {
incomingActive = true;
if (incomingActive && outgoingActive) {
@ -331,6 +342,37 @@ export class PortProxy {
}
};
// --- 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;
}
// 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) {
socket.setTimeout(5000, () => {
console.log(`Initial data timeout for ${remoteIP}`);
@ -363,7 +405,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(() => {
const now = Date.now();
let maxIncoming = 0;