fix(PortProxy): Refactor and optimize PortProxy for improved readability and maintainability

This commit is contained in:
Philipp Kunz 2025-02-23 17:30:41 +00:00
parent 133d5a47e0
commit b9210d891e
3 changed files with 125 additions and 199 deletions

View File

@ -1,5 +1,13 @@
# Changelog
## 2025-02-23 - 3.10.3 - fix(PortProxy)
Refactor and optimize PortProxy for improved readability and maintainability
- Simplified and clarified inline comments.
- Optimized the extractSNI function for better readability.
- Streamlined the cleanup process for connections in PortProxy.
- Improved handling and logging of incoming and outgoing connections.
## 2025-02-23 - 3.10.2 - fix(PortProxy)
Fix connection handling to include timeouts for SNI-enabled connections.

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.10.2',
version: '3.10.3',
description: 'a proxy for handling high workloads of proxying'
}

View File

@ -1,106 +1,73 @@
import * as plugins from './plugins.js';
export interface IDomainConfig {
domain: string; // glob pattern for domain
allowedIPs: string[]; // glob patterns for IPs allowed to access this domain
domain: string; // Glob pattern for domain
allowedIPs: string[]; // Glob patterns for allowed IPs
targetIP?: string; // Optional target IP for this domain
}
export interface IProxySettings extends plugins.tls.TlsOptions {
// Port configuration
fromPort: number;
toPort: number;
toHost?: string; // Target host to proxy to, defaults to 'localhost'
// Domain and security settings
toHost?: string; // Target host to proxy to, defaults to 'localhost'
domains: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[]; // Optional default IP patterns if no matching domain found
preserveSourceIP?: boolean; // Whether to preserve the client's source IP when proxying
defaultAllowedIPs?: string[];
preserveSourceIP?: boolean;
}
/**
* Extract SNI (Server Name Indication) from a TLS ClientHello packet.
* Returns the server name if found, or undefined.
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
* @param buffer - Buffer containing the TLS ClientHello.
* @returns The server name if found, otherwise undefined.
*/
function extractSNI(buffer: Buffer): string | undefined {
let offset = 0;
// We need at least 5 bytes for the record header.
if (buffer.length < 5) {
return undefined;
}
if (buffer.length < 5) return undefined;
// TLS record header
const recordType = buffer.readUInt8(0);
if (recordType !== 22) { // 22 = handshake
return undefined;
}
// Read record length
if (recordType !== 22) return undefined; // 22 = handshake
const recordLength = buffer.readUInt16BE(3);
if (buffer.length < 5 + recordLength) {
// Not all data arrived yet; in production you might need to accumulate more data.
return undefined;
}
if (buffer.length < 5 + recordLength) return undefined;
offset = 5;
// Handshake message type should be 1 for ClientHello.
const handshakeType = buffer.readUInt8(offset);
if (handshakeType !== 1) {
return undefined;
}
// Skip handshake header (1 byte type + 3 bytes length)
offset += 4;
if (handshakeType !== 1) return undefined; // 1 = ClientHello
// Skip client version (2 bytes) and random (32 bytes)
offset += 2 + 32;
offset += 4; // Skip handshake header (type + length)
offset += 2 + 32; // Skip client version and random
// Session ID
const sessionIDLength = buffer.readUInt8(offset);
offset += 1 + sessionIDLength;
offset += 1 + sessionIDLength; // Skip session ID
// Cipher suites
const cipherSuitesLength = buffer.readUInt16BE(offset);
offset += 2 + cipherSuitesLength;
offset += 2 + cipherSuitesLength; // Skip cipher suites
// Compression methods
const compressionMethodsLength = buffer.readUInt8(offset);
offset += 1 + compressionMethodsLength;
offset += 1 + compressionMethodsLength; // Skip compression methods
// Extensions length
if (offset + 2 > buffer.length) {
return undefined;
}
if (offset + 2 > buffer.length) return undefined;
const extensionsLength = buffer.readUInt16BE(offset);
offset += 2;
const extensionsEnd = offset + extensionsLength;
// Iterate over extensions
while (offset + 4 <= extensionsEnd) {
const extensionType = buffer.readUInt16BE(offset);
const extensionLength = buffer.readUInt16BE(offset + 2);
offset += 4;
// Check for SNI extension (type 0)
if (extensionType === 0x0000) {
// SNI extension: first 2 bytes are the SNI list length.
if (offset + 2 > buffer.length) {
return undefined;
}
if (extensionType === 0x0000) { // SNI extension
if (offset + 2 > buffer.length) return undefined;
const sniListLength = buffer.readUInt16BE(offset);
offset += 2;
const sniListEnd = offset + sniListLength;
// Loop through the list; typically there is one entry.
while (offset + 3 < sniListEnd) {
const nameType = buffer.readUInt8(offset);
offset++;
const nameType = buffer.readUInt8(offset++);
const nameLen = buffer.readUInt16BE(offset);
offset += 2;
if (nameType === 0) { // host_name
if (offset + nameLen > buffer.length) {
return undefined;
}
const serverName = buffer.toString('utf8', offset, offset + nameLen);
return serverName;
if (offset + nameLen > buffer.length) return undefined;
return buffer.toString('utf8', offset, offset + nameLen);
}
offset += nameLen;
}
@ -115,15 +82,11 @@ function extractSNI(buffer: Buffer): string | undefined {
export class PortProxy {
netServer: plugins.net.Server;
settings: IProxySettings;
// Track active incoming connections
private activeConnections: Set<plugins.net.Socket> = new Set();
// Record start times for incoming connections
private incomingConnectionTimes: Map<plugins.net.Socket, number> = new Map();
// Record start times for outgoing connections
private outgoingConnectionTimes: Map<plugins.net.Socket, number> = new Map();
private connectionLogger: NodeJS.Timeout | null = null;
// Overall termination statistics
private terminationStats: {
incoming: Record<string, number>;
outgoing: Record<string, number>;
@ -135,90 +98,66 @@ export class PortProxy {
constructor(settings: IProxySettings) {
this.settings = {
...settings,
toHost: settings.toHost || 'localhost'
toHost: settings.toHost || 'localhost',
};
}
// Helper to update termination stats.
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
if (!this.terminationStats[side][reason]) {
this.terminationStats[side][reason] = 1;
} else {
this.terminationStats[side][reason]++;
}
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
public async start() {
// Adjusted cleanUpSockets: forcefully destroy both sockets if they haven't been destroyed.
const cleanUpSockets = (from: plugins.net.Socket, to?: plugins.net.Socket) => {
if (!from.destroyed) {
from.destroy();
}
if (to && !to.destroyed) {
to.destroy();
}
// 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[] => {
// Handle IPv4-mapped IPv6 addresses
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7); // Remove '::ffff:' prefix
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
// Handle IPv4 addresses by adding IPv4-mapped IPv6 variant
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
const isAllowed = (value: string, patterns: string[]): boolean => {
// Expand patterns to include both IPv4 and IPv6 variants
// 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);
// Check if any variant of the IP matches any expanded pattern
return normalizeIP(value).some(ip =>
expandedPatterns.some(pattern => plugins.minimatch(ip, pattern))
return normalizedIPVariants.some(ipVariant =>
expandedPatterns.some(pattern => plugins.minimatch(ipVariant, pattern))
);
};
const findMatchingDomain = (serverName: string): IDomainConfig | undefined => {
return this.settings.domains.find(config => plugins.minimatch(serverName, config.domain));
};
// Find a matching domain config based on the SNI.
const findMatchingDomain = (serverName: string): IDomainConfig | undefined =>
this.settings.domains.find(config => plugins.minimatch(serverName, config.domain));
// Create a plain net server for TLS passthrough.
this.netServer = plugins.net.createServer((socket: plugins.net.Socket) => {
const remoteIP = socket.remoteAddress || '';
// Record start time for the incoming connection.
this.activeConnections.add(socket);
this.incomingConnectionTimes.set(socket, Date.now());
console.log(`New connection from ${remoteIP}. Active connections: ${this.activeConnections.size}`);
// Flag to detect if we've received the first data chunk.
let initialDataReceived = false;
// Local termination reason trackers for each side.
let incomingTermReason: string | null = null;
let outgoingTermReason: string | null = null;
// Immediately attach an error handler to catch early errors.
socket.on('error', (err: Error) => {
if (!initialDataReceived) {
console.log(`(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`);
} else {
console.log(`(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`);
}
});
let incomingTerminationReason: string | null = null;
let outgoingTerminationReason: string | null = null;
let targetSocket: plugins.net.Socket | null = null;
let connectionClosed = false;
// Ensure cleanup happens only once.
let connectionClosed = false;
const cleanupOnce = () => {
if (!connectionClosed) {
connectionClosed = true;
cleanUpSockets(socket, to || undefined);
cleanUpSockets(socket, targetSocket || undefined);
this.incomingConnectionTimes.delete(socket);
if (to) {
this.outgoingConnectionTimes.delete(to);
if (targetSocket) {
this.outgoingConnectionTimes.delete(targetSocket);
}
if (this.activeConnections.has(socket)) {
this.activeConnections.delete(socket);
@ -227,10 +166,24 @@ export class PortProxy {
}
};
// Outgoing connection placeholder.
let to: plugins.net.Socket | null = null;
// Helper to reject an incoming connection.
const rejectIncomingConnection = (reason: string, logMessage: string) => {
console.log(logMessage);
socket.end();
if (incomingTerminationReason === null) {
incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
cleanupOnce();
};
socket.on('error', (err: Error) => {
const errorMessage = initialDataReceived
? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`
: `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`;
console.log(errorMessage);
});
// Handle errors by recording termination reason and cleaning up.
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
const code = (err as any).code;
let reason = 'error';
@ -240,73 +193,47 @@ export class PortProxy {
} else {
console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
}
if (side === 'incoming' && incomingTermReason === null) {
incomingTermReason = reason;
if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
} else if (side === 'outgoing' && outgoingTermReason === null) {
outgoingTermReason = reason;
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason);
}
cleanupOnce();
};
// Handle close events. If no termination reason was recorded, mark as "normal".
const handleClose = (side: 'incoming' | 'outgoing') => () => {
console.log(`Connection closed on ${side} side from ${remoteIP}`);
if (side === 'incoming' && incomingTermReason === null) {
incomingTermReason = 'normal';
if (side === 'incoming' && incomingTerminationReason === null) {
incomingTerminationReason = 'normal';
this.incrementTerminationStat('incoming', 'normal');
} else if (side === 'outgoing' && outgoingTermReason === null) {
outgoingTermReason = 'normal';
} else if (side === 'outgoing' && outgoingTerminationReason === null) {
outgoingTerminationReason = 'normal';
this.incrementTerminationStat('outgoing', 'normal');
}
cleanupOnce();
};
// Setup connection, optionally accepting the initial data chunk.
const setupConnection = (serverName: string, initialChunk?: Buffer) => {
// Check if the IP is allowed by default.
const isDefaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
if (!isDefaultAllowed && serverName) {
const defaultAllowed = this.settings.defaultAllowedIPs && isAllowed(remoteIP, this.settings.defaultAllowedIPs);
if (!defaultAllowed && serverName) {
const domainConfig = findMatchingDomain(serverName);
if (!domainConfig) {
console.log(`Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`);
socket.end();
if (incomingTermReason === null) {
incomingTermReason = 'rejected';
this.incrementTerminationStat('incoming', 'rejected');
}
cleanupOnce();
return;
return rejectIncomingConnection('rejected', `Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`);
}
if (!isAllowed(remoteIP, domainConfig.allowedIPs)) {
console.log(`Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`);
socket.end();
if (incomingTermReason === null) {
incomingTermReason = 'rejected';
this.incrementTerminationStat('incoming', 'rejected');
}
cleanupOnce();
return;
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`);
}
} else if (!isDefaultAllowed && !serverName) {
console.log(`Connection rejected: No SNI and IP ${remoteIP} not in default allowed list`);
socket.end();
if (incomingTermReason === null) {
incomingTermReason = 'rejected';
this.incrementTerminationStat('incoming', 'rejected');
}
cleanupOnce();
return;
} else {
} 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`);
}
// Determine target host.
const domainConfig = serverName ? findMatchingDomain(serverName) : undefined;
const targetHost = domainConfig?.targetIP || this.settings.toHost!;
// Create connection options.
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: this.settings.toPort,
@ -315,49 +242,47 @@ export class PortProxy {
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
}
// Establish outgoing connection.
to = plugins.net.connect(connectionOptions);
if (to) {
this.outgoingConnectionTimes.set(to, Date.now());
targetSocket = plugins.net.connect(connectionOptions);
if (targetSocket) {
this.outgoingConnectionTimes.set(targetSocket, Date.now());
}
console.log(`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}${serverName ? ` (SNI: ${serverName})` : ''}`);
// Push back the initial chunk if provided.
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` +
`${serverName ? ` (SNI: ${serverName})` : ''}`
);
if (initialChunk) {
socket.unshift(initialChunk);
}
socket.setTimeout(120000);
socket.pipe(to!);
to!.pipe(socket);
socket.pipe(targetSocket);
targetSocket.pipe(socket);
// Attach event handlers for both sockets.
socket.on('error', handleError('incoming'));
to!.on('error', handleError('outgoing'));
targetSocket.on('error', handleError('outgoing'));
socket.on('close', handleClose('incoming'));
to!.on('close', handleClose('outgoing'));
targetSocket.on('close', handleClose('outgoing'));
socket.on('timeout', () => {
console.log(`Timeout on incoming side from ${remoteIP}`);
if (incomingTermReason === null) {
incomingTermReason = 'timeout';
if (incomingTerminationReason === null) {
incomingTerminationReason = 'timeout';
this.incrementTerminationStat('incoming', 'timeout');
}
cleanupOnce();
});
to!.on('timeout', () => {
targetSocket.on('timeout', () => {
console.log(`Timeout on outgoing side from ${remoteIP}`);
if (outgoingTermReason === null) {
outgoingTermReason = 'timeout';
if (outgoingTerminationReason === null) {
outgoingTerminationReason = 'timeout';
this.incrementTerminationStat('outgoing', 'timeout');
}
cleanupOnce();
});
socket.on('end', handleClose('incoming'));
to!.on('end', handleClose('outgoing'));
targetSocket.on('end', handleClose('outgoing'));
};
// For SNI-enabled connections, set an initial data timeout before waiting for data.
if (this.settings.sniEnabled) {
// Set an initial timeout for receiving data (e.g., 5 seconds)
socket.setTimeout(5000, () => {
console.log(`Initial data timeout for ${remoteIP}`);
socket.end();
@ -365,7 +290,6 @@ export class PortProxy {
});
socket.once('data', (chunk: Buffer) => {
// Clear the initial timeout since data has been received
socket.setTimeout(0);
initialDataReceived = true;
const serverName = extractSNI(chunk) || '';
@ -373,27 +297,22 @@ export class PortProxy {
setupConnection(serverName, chunk);
});
} else {
// For non-SNI connections, simply check defaultAllowedIPs.
initialDataReceived = true;
if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
console.log(`Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
socket.end();
if (incomingTermReason === null) {
incomingTermReason = 'rejected';
this.incrementTerminationStat('incoming', 'rejected');
}
cleanupOnce();
return;
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
}
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)' : ''}`);
});
.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)' : ''}`
);
});
// Log active connection count, longest running connection durations,
// and termination statistics every 10 seconds.
@ -401,19 +320,18 @@ export class PortProxy {
const now = Date.now();
let maxIncoming = 0;
for (const startTime of this.incomingConnectionTimes.values()) {
const duration = now - startTime;
if (duration > maxIncoming) {
maxIncoming = duration;
}
maxIncoming = Math.max(maxIncoming, now - startTime);
}
let maxOutgoing = 0;
for (const startTime of this.outgoingConnectionTimes.values()) {
const duration = now - startTime;
if (duration > maxOutgoing) {
maxOutgoing = duration;
}
maxOutgoing = Math.max(maxOutgoing, now - startTime);
}
console.log(`(Interval Log) Active connections: ${this.activeConnections.size}. Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. Termination stats (incoming): ${JSON.stringify(this.terminationStats.incoming)}, (outgoing): ${JSON.stringify(this.terminationStats.outgoing)}`);
console.log(
`(Interval Log) Active connections: ${this.activeConnections.size}. ` +
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
`Termination stats (incoming): ${JSON.stringify(this.terminationStats.incoming)}, ` +
`(outgoing): ${JSON.stringify(this.terminationStats.outgoing)}`
);
}, 10000);
}