Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
.nyc_output
.vscode
dist
npm-debug.log
lib-cov
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simple-socks",
"version": "3.4.0",
"version": "3.5.0",
"description": "proxies requests ",
"type": "module",
"main": "src/socks5.js",
Expand All @@ -19,7 +19,8 @@
"contributors": [
"https://github.com/pronskiy",
"https://github.com/fabiensk",
"https://github.com/lanius-collaris"
"https://github.com/lanius-collaris",
"https://github.com/adsr"
],
"license": "MIT",
"keywords": [
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ This method accepts an optional `options` argument:

- `options.authentication` - A callback for authentication
- `options.connectionFilter` - A callback for connection filtering
- `options.connectTimeout` - Milliseconds to wait for destination connect phase before failing the request (0 is disabled, default 0)
- `options.idleTimeout` - Milliseconds of inactivity before destroying client/destination sockets (0 is disabled, default 0)
- `options.destinationIdleTimeout` - Milliseconds of inactivity before destroying destination sockets; falls back to `options.idleTimeout` when not explicitly set
- `options.compatAuth` - Non-default RFC 1929 compatibility controls for empty credentials (defaults are strict)

#### authentication callback
Expand Down
59 changes: 44 additions & 15 deletions src/socks5.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ class SocksServer {
}

this.idleTimeout = this.options.idleTimeout || 0;
this.connectTimeout = this.options.connectTimeout || 0;
this.destinationIdleTimeout = Object.prototype.hasOwnProperty.call(
this.options,
'destinationIdleTimeout',
)
? this.options.destinationIdleTimeout
: this.idleTimeout;

this.server = net.createServer((socket) => {
socket.on('error', (err) => {
self.server.emit(EVENTS.PROXY_ERROR, err);
Expand Down Expand Up @@ -318,8 +326,9 @@ class SocksServer {
const destination = net.createConnection(
{
host: args.dst.addr,
port: args.dst.port,
localAddress: self.options.localAddress,
localPort: self.options.localPort,
port: args.dst.port,
},
() => {
// prepare a success response
Expand All @@ -337,18 +346,21 @@ class SocksServer {

// configure idle timeout for destination socket
if (
self.idleTimeout
self.destinationIdleTimeout
&& typeof destination.setTimeout === 'function'
) {
destination.setTimeout(self.idleTimeout, () => {
try {
destination.destroy(
new Error('destination idle timeout'),
);
} catch {
// ignore errors
}
});
destination.setTimeout(
self.destinationIdleTimeout,
() => {
try {
destination.destroy(
new Error('destination idle timeout'),
);
} catch {
// ignore socket destroy errors
}
},
);
}

// ensure proper teardown when either side ends/closes/errors
Expand Down Expand Up @@ -436,10 +448,27 @@ class SocksServer {
return end(RFC_1928_REPLIES.NETWORK_UNREACHABLE, args);
});

if (self.options.connTimeout) {
destination.setTimeout(self.options.connTimeout);
destination.on('timeout', () => {
destination.destroy();
if (
self.connectTimeout
&& typeof destination.setTimeout === 'function'
) {
const onConnectTimeout = () => {
const timeoutError = new Error('destination connect timeout');
timeoutError.code = 'ETIMEDOUT';
try {
destination.destroy(timeoutError);
} catch {
// ignore socket destroy errors
}
};

destination.setTimeout(self.connectTimeout);
destination.once('timeout', onConnectTimeout);
destination.once('connect', () => {
destination.off('timeout', onConnectTimeout);
if (!self.destinationIdleTimeout) {
destination.setTimeout(0);
}
});
}
}),
Expand Down
93 changes: 93 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,36 @@ function createMockDestinationSocket(error) {
return destination;
}

function createMockPendingDestinationSocket() {
const destination = new EventEmitter();
let timeoutId;
destination.pipe = () => destination;
destination.setTimeout = (ms) => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
if (ms > 0) {
timeoutId = setTimeout(() => {
destination.emit('timeout');
}, ms);
}
return destination;
};
destination.destroy = (err) => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
if (err) {
setImmediate(() => destination.emit('error', err));
}
setImmediate(() => destination.emit('close', Boolean(err)));
return destination;
};
return destination;
}

await test('no-auth: connect to local echo', async () => {
const echo = await createEchoServer();
const app = socks5.createServer();
Expand Down Expand Up @@ -186,6 +216,69 @@ await test('activeSessions returns to 0 after idle timeout', async () => {
await closeServer(echo.server);
});

await test('destinationIdleTimeout destroys idle destination socket', async () => {
const echo = await createEchoServer();
const app = socks5.createServer({ destinationIdleTimeout: 50 });
await listenServer(app);
const addr = app.address();

const client = await connectTo(addr.port, addr.address);
client.write(buildSocks5Handshake(0x00));
const selection = await readExactly(client, 2);
assert.strictEqual(selection[0], 0x05);
assert.strictEqual(selection[1], 0x00);

client.write(buildSocks5ConnectRequest(echo.host, echo.port));
const connectResponse = await readExactly(client, 2);
assert.strictEqual(connectResponse[0], 0x05);
assert.strictEqual(connectResponse[1], 0x00);

const disconnect = await Promise.race([
once(app, 'proxyDisconnect'),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('expected proxyDisconnect from destinationIdleTimeout')),
500,
)
),
]);
assert.ok(disconnect);

client.destroy();
await closeServer(app);
await closeServer(echo.server);
});

await test('connectTimeout fails destination connection when connect phase is too slow', async () => {
const app = socks5.createServer({ connectTimeout: 25, destinationIdleTimeout: 0 });
await listenServer(app);
const addr = app.address();
const client = await connectTo(addr.port, addr.address);

client.write(buildSocks5Handshake(0x00));
const selection = await readExactly(client, 2);
assert.strictEqual(selection[0], 0x05);
assert.strictEqual(selection[1], 0x00);

const originalCreateConnection = net.createConnection;
try {
net.createConnection = () => createMockPendingDestinationSocket();

const proxyErrorPromise = once(app, 'proxyError');
client.write(buildSocks5ConnectRequest('127.0.0.1', 80));
const response = await readExactly(client, 2);
assert.strictEqual(response[0], 0x05);
assert.strictEqual(response[1], 0x03);

const proxyError = await proxyErrorPromise;
assert.strictEqual(proxyError.code, 'ETIMEDOUT');
} finally {
net.createConnection = originalCreateConnection;
client.destroy();
await closeServer(app);
}
});

await test('invalid handshake version returns general failure', async () => {
const app = socks5.createServer();
await listenServer(app);
Expand Down
Loading