Compare commits

...

12 Commits

5 changed files with 110 additions and 60 deletions

View File

@ -1,5 +1,44 @@
# Changelog
## 2025-02-27 - 3.16.9 - fix(portproxy)
Extend domain input validation to support string arrays in port proxy configurations.
- Modify IDomainConfig interface to allow domain specification as string array.
- Update connection setup logic to handle multiple domain patterns.
- Enhance domain rejection logging to include all domain patterns.
## 2025-02-27 - 3.16.8 - fix(PortProxy)
Fix IP filtering for domain and global default allowed lists and improve port-based routing logic.
- Improved logic to prioritize domain-specific allowed IPs over global defaults.
- Fixed port-based rules application to handle global port ranges more effectively.
- Enhanced rejection handling for unauthorized IP addresses in both domain-specific and default global lists.
## 2025-02-27 - 3.16.7 - fix(PortProxy)
Improved IP validation logic in PortProxy to ensure correct domain matching and fallback
- Refactored the setupConnection function inside PortProxy to enhance IP address validation.
- Domain-specific allowed IP preference is applied before default list lookup.
- Removed redundant condition checks to streamline connection rejection paths.
## 2025-02-27 - 3.16.6 - fix(PortProxy)
Optimize connection cleanup logic in PortProxy by removing unnecessary delays.
- Removed multiple await plugins.smartdelay.delayFor(0) calls.
- Improved performance by ensuring timely resource release during connection termination.
## 2025-02-27 - 3.16.5 - fix(PortProxy)
Improved connection cleanup process with added asynchronous delays
- Connection cleanup now includes asynchronous delays for reliable order of operations.
## 2025-02-27 - 3.16.4 - fix(PortProxy)
Fix and enhance port proxy handling
- Ensure that all created proxy servers are correctly checked for listening state.
- Corrected the handling of ports and domain configurations within port proxy setups.
- Expanded test coverage for handling multiple concurrent and chained proxy connections.
## 2025-02-27 - 3.16.3 - fix(PortProxy)
Refactored PortProxy to support multiple listening ports and improved modularity.

View File

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

@ -16,12 +16,10 @@ function createTestServer(port: number): Promise<net.Server> {
// Echo the received data back
socket.write(`Echo: ${data.toString()}`);
});
socket.on('error', (error) => {
console.error('[Test Server] Socket error:', error);
});
});
server.listen(port, () => {
console.log(`[Test Server] Listening on port ${port}`);
resolve(server);
@ -39,16 +37,13 @@ function createTestClient(port: number, data: string): Promise<string> {
console.log('[Test Client] Connected to server');
client.write(data);
});
client.on('data', (chunk) => {
response += chunk.toString();
client.end();
});
client.on('end', () => {
resolve(response);
});
client.on('error', (error) => {
reject(error);
});
@ -61,16 +56,18 @@ tap.test('setup port proxy test environment', async () => {
portProxy = new PortProxy({
fromPort: PROXY_PORT,
toPort: TEST_SERVER_PORT,
toHost: 'localhost',
targetIP: 'localhost',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1']
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
});
tap.test('should start port proxy', async () => {
await portProxy.start();
expect(portProxy.netServer.listening).toBeTrue();
// Since netServers is private, we cast to any to verify that all created servers are listening.
expect((portProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue();
});
tap.test('should forward TCP connections and data to localhost', async () => {
@ -79,14 +76,15 @@ tap.test('should forward TCP connections and data to localhost', async () => {
});
tap.test('should forward TCP connections to custom host', async () => {
// Create a new proxy instance with a custom host
// Create a new proxy instance with a custom host (targetIP)
const customHostProxy = new PortProxy({
fromPort: PROXY_PORT + 1,
toPort: TEST_SERVER_PORT,
toHost: '127.0.0.1',
targetIP: '127.0.0.1',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1']
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
await customHostProxy.start();
@ -103,8 +101,8 @@ tap.test('should forward connections based on domain-specific target IP', async
// Create a proxy with domain-specific target IPs
const domainProxy = new PortProxy({
fromPort: PROXY_PORT + 2,
toPort: TEST_SERVER_PORT, // default port
toHost: 'localhost', // default host
toPort: TEST_SERVER_PORT, // default port (for non-port-range handling)
targetIP: 'localhost', // default target IP
domains: [{
domain: 'domain1.test',
allowedIPs: ['127.0.0.1'],
@ -114,24 +112,26 @@ tap.test('should forward connections based on domain-specific target IP', async
allowedIPs: ['127.0.0.1'],
targetIP: 'localhost'
}],
sniEnabled: false, // We'll test without SNI first since this is a TCP proxy test
defaultAllowedIPs: ['127.0.0.1']
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
await domainProxy.start();
// Test default connection (should use default host)
// Test default connection (should use default targetIP)
const response1 = await createTestClient(PROXY_PORT + 2, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
// Create another proxy with different default host
// Create another proxy with a different default targetIP
const domainProxy2 = new PortProxy({
fromPort: PROXY_PORT + 3,
toPort: TEST_SERVER_PORT,
toHost: '127.0.0.1',
targetIP: '127.0.0.1',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1']
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
await domainProxy2.start();
@ -158,7 +158,6 @@ tap.test('should handle multiple concurrent connections', async () => {
tap.test('should handle connection timeouts', async () => {
const client = new net.Socket();
await new Promise<void>((resolve) => {
client.connect(PROXY_PORT, 'localhost', () => {
// Don't send any data, just wait for timeout
@ -171,28 +170,30 @@ tap.test('should handle connection timeouts', async () => {
tap.test('should stop port proxy', async () => {
await portProxy.stop();
expect(portProxy.netServer.listening).toBeFalse();
expect((portProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
});
// Cleanup
// Cleanup chained proxies tests
tap.test('should support optional source IP preservation in chained proxies', async () => {
// Test 1: Without IP preservation (default behavior)
const firstProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 4,
toPort: PROXY_PORT + 5,
toHost: 'localhost',
targetIP: 'localhost',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
const secondProxyDefault = new PortProxy({
fromPort: PROXY_PORT + 5,
toPort: TEST_SERVER_PORT,
toHost: 'localhost',
targetIP: 'localhost',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1']
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
await secondProxyDefault.start();
@ -209,21 +210,23 @@ tap.test('should support optional source IP preservation in chained proxies', as
const firstProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 6,
toPort: PROXY_PORT + 7,
toHost: 'localhost',
targetIP: 'localhost',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true
preserveSourceIP: true,
globalPortRanges: []
});
const secondProxyPreserved = new PortProxy({
fromPort: PROXY_PORT + 7,
toPort: TEST_SERVER_PORT,
toHost: 'localhost',
targetIP: 'localhost',
domains: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true
preserveSourceIP: true,
globalPortRanges: []
});
await secondProxyPreserved.start();
@ -245,9 +248,10 @@ process.on('exit', () => {
if (testServer) {
testServer.close();
}
if (portProxy && portProxy.netServer) {
// Use a cast to access the private property for cleanup.
if (portProxy && (portProxy as any).netServers) {
portProxy.stop();
}
});
export default tap.start();
export default tap.start();

View File

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

@ -2,9 +2,9 @@ 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
domain: string | string[]; // Glob pattern or patterns for domain(s)
allowedIPs: string[]; // Glob patterns for allowed IPs
targetIP?: string; // Optional target IP for this domain
portRanges?: Array<{ from: number; to: number }>; // Optional domain-specific allowed port ranges
}
@ -140,7 +140,7 @@ export class PortProxy {
let outgoingTerminationReason: string | null = null;
// Ensure cleanup happens only once for the entire connection record.
const cleanupOnce = () => {
const cleanupOnce = async () => {
if (!connectionRecord.connectionClosed) {
connectionRecord.connectionClosed = true;
if (connectionRecord.cleanupTimer) {
@ -207,26 +207,35 @@ export class PortProxy {
* @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).
* @param overridePort - If provided, use this port for the outgoing connection (typically the same as the incoming port).
*/
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig) => {
const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
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);
const domainConfig = forcedDomain
? forcedDomain
: (serverName ? this.settings.domains.find(config => {
if (typeof config.domain === 'string') {
return plugins.minimatch(serverName, config.domain);
} else {
return config.domain.some(d => plugins.minimatch(serverName, d));
}
}) : undefined);
if (!defaultAllowed && serverName && !forcedDomain) {
if (!domainConfig) {
return rejectIncomingConnection('rejected', `Connection rejected: No matching domain config for ${serverName} from ${remoteIP}`);
}
// If a matching domain config exists, check its allowedIPs.
if (domainConfig) {
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 ${Array.isArray(domainConfig.domain) ? domainConfig.domain.join(', ') : domainConfig.domain}`);
}
} else if (this.settings.defaultAllowedIPs) {
// Only check default allowed IPs if no domain config matched.
if (!isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
}
} else if (defaultAllowed && !serverName) {
console.log(`Connection allowed: IP ${remoteIP} is in default allowed list`);
}
const targetHost = domainConfig?.targetIP || this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: this.settings.toPort,
port: overridePort !== undefined ? overridePort : this.settings.toPort,
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
@ -237,8 +246,8 @@ export class PortProxy {
connectionRecord.outgoingStartTime = Date.now();
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domain})` : ''}`
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain})` : ''}`
);
if (initialChunk) {
@ -310,9 +319,8 @@ export class PortProxy {
};
// --- PORT RANGE-BASED HANDLING ---
// 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 forwardAllGlobalRanges is enabled, always forward using the global targetIP.
// Only apply port-based rules if the incoming port is within one of the global port ranges.
if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) {
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.`);
@ -325,7 +333,7 @@ export class PortProxy {
allowedIPs: this.settings.defaultAllowedIPs || [],
targetIP: this.settings.targetIP,
portRanges: []
});
}, localPort);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
@ -333,14 +341,13 @@ export class PortProxy {
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}.`);
if (!isAllowed(remoteIP, forcedDomain.allowedIPs)) {
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : 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);
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${Array.isArray(forcedDomain.domain) ? forcedDomain.domain.join(', ') : forcedDomain.domain}.`);
setupConnection('', undefined, forcedDomain, localPort);
return;
}
// Fall through to SNI/default handling if no forced domain config is found.