From f6c3d2d3d00982a134425c32f816dfb68660911d Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Thu, 27 Feb 2025 12:25:48 +0000 Subject: [PATCH] feat(classes.portproxy): Add support for port range-based routing with enhanced IP and port validation. --- changelog.md | 7 ++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.portproxy.ts | 72 +++++++++++++++++++++++++++++++--------- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/changelog.md b/changelog.md index 9421018..72fd4f8 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index d1ac2ea..1646f74 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/classes.portproxy.ts b/ts/classes.portproxy.ts index 5bfa003..cc381e8 100644 --- a/ts/classes.portproxy.ts +++ b/ts/classes.portproxy.ts @@ -1,11 +1,14 @@ import * as plugins from './plugins.js'; +/** Domain configuration with per‐domain 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 aren’t 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;