Compare commits

...

8 Commits

4 changed files with 135 additions and 86 deletions

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "3.15.0",
"version": "3.16.3",
"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.",
"main": "dist_ts/index.js",

View File

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

@ -5,20 +5,21 @@ 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
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 {
fromPort: 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[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
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
}
/**
@ -94,7 +95,7 @@ interface IConnectionRecord {
}
export class PortProxy {
netServer: plugins.net.Server;
private netServers: plugins.net.Server[] = [];
settings: IPortProxySettings;
// Unified record tracking each connection pair.
private connectionRecords: Set<IConnectionRecord> = new Set();
@ -111,7 +112,7 @@ export class PortProxy {
constructor(settingsArg: IPortProxySettings) {
this.settings = {
...settingsArg,
toHost: settingsArg.toHost || 'localhost',
targetIP: settingsArg.targetIP || 'localhost',
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
};
}
@ -121,43 +122,8 @@ export class PortProxy {
}
public async start() {
// Helper to forcefully destroy sockets.
const cleanUpSockets = (socketA: plugins.net.Socket, socketB?: 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))
);
};
// 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) => {
// Define a unified connection handler for all listening ports.
const connectionHandler = (socket: plugins.net.Socket) => {
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort; // The port on which this connection was accepted.
const connectionRecord: IConnectionRecord = {
@ -180,7 +146,8 @@ export class PortProxy {
if (connectionRecord.cleanupTimer) {
clearTimeout(connectionRecord.cleanupTimer);
}
cleanUpSockets(connectionRecord.incoming, connectionRecord.outgoing || undefined);
if (!socket.destroyed) socket.destroy();
if (connectionRecord.outgoing && !connectionRecord.outgoing.destroyed) connectionRecord.outgoing.destroy();
this.connectionRecords.delete(connectionRecord);
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
}
@ -243,7 +210,7 @@ export class PortProxy {
*/
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 domainConfig = forcedDomain ? forcedDomain : (serverName ? this.settings.domains.find(config => plugins.minimatch(serverName, config.domain)) : undefined);
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
if (!defaultAllowed && serverName && !forcedDomain) {
@ -256,7 +223,7 @@ export class PortProxy {
} else if (defaultAllowed && !serverName) {
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`);
}
const targetHost = domainConfig?.targetIP || this.settings.toHost!;
const targetHost = domainConfig?.targetIP || this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: this.settings.toPort,
@ -343,36 +310,44 @@ export class PortProxy {
};
// --- PORT RANGE-BASED HANDLING ---
// If global port ranges are defined, enforce port-based routing and ignore SNI.
// 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 (!isPortInRanges(localPort, this.settings.globalPortRanges)) {
console.log(`Connection from ${remoteIP} rejected: port ${localPort} is not in global allowed ranges.`);
socket.destroy();
// 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: []
});
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) {
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}.`);
setupConnection('', undefined, forcedDomain);
return;
}
// Fall through to SNI/default handling if no forced domain config is found.
}
// 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) ---
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if (this.settings.sniEnabled) {
socket.setTimeout(5000, () => {
console.log(`Initial data timeout for ${remoteIP}`);
@ -394,16 +369,36 @@ export class PortProxy {
}
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)' : ''}`
);
};
// --- 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(() => {
@ -426,14 +421,41 @@ export class PortProxy {
}
public async stop() {
const done = plugins.smartpromise.defer();
this.netServer.close(() => {
done.resolve();
});
// Close all servers.
const closePromises: Promise<void>[] = this.netServers.map(
server =>
new Promise<void>((resolve) => {
server.close(() => resolve());
})
);
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
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))
);
};