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 # 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) ## 2025-02-26 - 3.14.2 - fix(PortProxy)
Fix cleanup timer reset for PortProxy Fix cleanup timer reset for PortProxy

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', 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.' 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'; 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 }>; // 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;
@ -14,7 +17,8 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
sniEnabled?: boolean; sniEnabled?: boolean;
defaultAllowedIPs?: string[]; defaultAllowedIPs?: string[];
preserveSourceIP?: boolean; 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 => 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,
@ -157,7 +167,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;
@ -225,24 +235,27 @@ 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 domainConfig = serverName ? findMatchingDomain(serverName) : undefined;
const targetHost = domainConfig?.targetIP || this.settings.toHost!; const targetHost = domainConfig?.targetIP || this.settings.toHost!;
const connectionOptions: plugins.net.NetConnectOpts = { const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost, host: targetHost,
@ -258,7 +271,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) {
@ -292,7 +305,7 @@ export class PortProxy {
socket.on('end', handleClose('incoming')); socket.on('end', handleClose('incoming'));
targetSocket.on('end', handleClose('outgoing')); 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) { if (this.settings.maxConnectionLifetime) {
let incomingActive = false; let incomingActive = false;
let outgoingActive = false; let outgoingActive = false;
@ -308,10 +321,8 @@ export class PortProxy {
} }
}; };
// Start the cleanup timer.
resetCleanupTimer(); resetCleanupTimer();
// Listen for data events on both sides and reset the timer when both are active.
socket.on('data', () => { socket.on('data', () => {
incomingActive = true; incomingActive = true;
if (incomingActive && outgoingActive) { 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) { if (this.settings.sniEnabled) {
socket.setTimeout(5000, () => { socket.setTimeout(5000, () => {
console.log(`Initial data timeout for ${remoteIP}`); 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(() => { this.connectionLogger = setInterval(() => {
const now = Date.now(); const now = Date.now();
let maxIncoming = 0; let maxIncoming = 0;