Skip to content

Commit 223cc72

Browse files
committed
Connection timeout
Added ability to enforce max connection timeout. It is applied to new connections and makes them terminate connection attempt after configured number of milliseconds. Feature is needed because connect part might take minutes in some environments. NodeJS channel uses `net.Socket` when non-encrypted and it's subclass `tls.TLSSocket` when encrypted. Connection timeout is implemented using `socket.setTimeout()` function which triggers a callback after configured millis of inactivity. We first initiate a connection attempt and them set the timeout. If connection establishes before the timeout then later is canceled. Otherwise `timeout` event is fired and socket is destroyed. WebSocket channel used in browser does not have built-in timeout functionality. That is why we simply use `setTimeout()` that closes WebSocket if it does not connect in time. Successful connection cancels the timeout. Feature is turned of by default and can be configured using `connectionTimeout` property in the config. Example: ``` const driver = neo4j.driver( 'bolt://localhost', neo4j.auth.basic('neo4j', 'pwd'), {connectionTimeout: 5000 /* 5 seconds */ }); ```
1 parent 82af0d8 commit 223cc72

File tree

7 files changed

+241
-61
lines changed

7 files changed

+241
-61
lines changed

src/v1/index.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@ const USER_AGENT = "neo4j-javascript/" + VERSION;
116116
* // {@link Session#readTransaction()} and {@link Session#writeTransaction()} functions. These functions
117117
* // will retry the given unit of work on `ServiceUnavailable`, `SessionExpired` and transient errors with
118118
* // exponential backoff using initial delay of 1 second. Default value is 30000 which is 30 seconds.
119-
* maxTransactionRetryTime: 30000,
119+
* maxTransactionRetryTime: 30000, // 30 seconds
120+
*
121+
* // Specify socket connection timeout in milliseconds. Non-numeric, negative and zero values are treated as an
122+
* // infinite timeout. Connection will be then bound by the timeout configured on the operating system level.
123+
* // Timeout value should be numeric and greater or equal to zero.
124+
* connectionTimeout: 5000, // 5 seconds
120125
* }
121126
*
122127
* @param {string} url The URL for the Neo4j database, for instance "bolt://localhost"

src/v1/internal/ch-config.js

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,38 +20,49 @@
2020
import hasFeature from './features';
2121
import {SERVICE_UNAVAILABLE} from '../error';
2222

23+
const DEFAULT_CONNECTION_TIMEOUT_MILLIS = 0; // turned off by default
24+
2325
export default class ChannelConfig {
2426

2527
constructor(host, port, driverConfig, connectionErrorCode) {
2628
this.host = host;
2729
this.port = port;
28-
this.encrypted = ChannelConfig._extractEncrypted(driverConfig);
29-
this.trust = ChannelConfig._extractTrust(driverConfig);
30-
this.trustedCertificates = ChannelConfig._extractTrustedCertificates(driverConfig);
31-
this.knownHostsPath = ChannelConfig._extractKnownHostsPath(driverConfig);
30+
this.encrypted = extractEncrypted(driverConfig);
31+
this.trust = extractTrust(driverConfig);
32+
this.trustedCertificates = extractTrustedCertificates(driverConfig);
33+
this.knownHostsPath = extractKnownHostsPath(driverConfig);
3234
this.connectionErrorCode = connectionErrorCode || SERVICE_UNAVAILABLE;
35+
this.connectionTimeout = extractConnectionTimeout(driverConfig);
3336
}
37+
}
3438

35-
static _extractEncrypted(driverConfig) {
36-
// check if encryption was configured by the user, use explicit null check because we permit boolean value
37-
const encryptionConfigured = driverConfig.encrypted == null;
38-
// default to using encryption if trust-all-certificates is available
39-
return encryptionConfigured ? hasFeature('trust_all_certificates') : driverConfig.encrypted;
40-
}
39+
function extractEncrypted(driverConfig) {
40+
// check if encryption was configured by the user, use explicit null check because we permit boolean value
41+
const encryptionConfigured = driverConfig.encrypted == null;
42+
// default to using encryption if trust-all-certificates is available
43+
return encryptionConfigured ? hasFeature('trust_all_certificates') : driverConfig.encrypted;
44+
}
4145

42-
static _extractTrust(driverConfig) {
43-
if (driverConfig.trust) {
44-
return driverConfig.trust;
45-
}
46-
// default to using TRUST_ALL_CERTIFICATES if it is available
47-
return hasFeature('trust_all_certificates') ? 'TRUST_ALL_CERTIFICATES' : 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES';
46+
function extractTrust(driverConfig) {
47+
if (driverConfig.trust) {
48+
return driverConfig.trust;
4849
}
50+
// default to using TRUST_ALL_CERTIFICATES if it is available
51+
return hasFeature('trust_all_certificates') ? 'TRUST_ALL_CERTIFICATES' : 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES';
52+
}
4953

50-
static _extractTrustedCertificates(driverConfig) {
51-
return driverConfig.trustedCertificates || [];
52-
}
54+
function extractTrustedCertificates(driverConfig) {
55+
return driverConfig.trustedCertificates || [];
56+
}
57+
58+
function extractKnownHostsPath(driverConfig) {
59+
return driverConfig.knownHosts || null;
60+
}
5361

54-
static _extractKnownHostsPath(driverConfig) {
55-
return driverConfig.knownHosts || null;
62+
function extractConnectionTimeout(driverConfig) {
63+
const configuredTimeout = parseInt(driverConfig.connectionTimeout, 10);
64+
if (!configuredTimeout || configuredTimeout < 0) {
65+
return DEFAULT_CONNECTION_TIMEOUT_MILLIS;
5666
}
57-
};
67+
return configuredTimeout;
68+
}

src/v1/internal/ch-node.js

Lines changed: 86 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,14 @@ const TrustStrategy = {
123123
return;
124124
}
125125

126-
let tlsOpts = {
126+
const tlsOpts = {
127127
ca: config.trustedCertificates.map((f) => fs.readFileSync(f)),
128128
// Because we manually check for this in the connect callback, to give
129129
// a more helpful error to the user
130130
rejectUnauthorized: false
131131
};
132132

133-
let socket = tls.connect(config.port, config.host, tlsOpts, function () {
133+
const connectListener = socket => {
134134
if (!socket.authorized) {
135135
onFailure(newError("Server certificate is not trusted. If you trust the database you are connecting to, add" +
136136
" the signing certificate, or the server certificate, to the list of certificates trusted by this driver" +
@@ -141,18 +141,19 @@ const TrustStrategy = {
141141
} else {
142142
onSuccess();
143143
}
144-
});
145-
socket.on('error', onFailure);
146-
return socket;
144+
};
145+
146+
return connectTlsSocket(config, tlsOpts, connectListener, onFailure);
147147
},
148148
TRUST_SYSTEM_CA_SIGNED_CERTIFICATES : function( config, onSuccess, onFailure ) {
149149

150-
let tlsOpts = {
150+
const tlsOpts = {
151151
// Because we manually check for this in the connect callback, to give
152152
// a more helpful error to the user
153153
rejectUnauthorized: false
154154
};
155-
let socket = tls.connect(config.port, config.host, tlsOpts, function () {
155+
156+
const connectListener = socket => {
156157
if (!socket.authorized) {
157158
onFailure(newError("Server certificate is not trusted. If you trust the database you are connecting to, use " +
158159
"TRUST_CUSTOM_CA_SIGNED_CERTIFICATES and add" +
@@ -164,9 +165,9 @@ const TrustStrategy = {
164165
} else {
165166
onSuccess();
166167
}
167-
});
168-
socket.on('error', onFailure);
169-
return socket;
168+
};
169+
170+
return connectTlsSocket(config, tlsOpts, connectListener, onFailure);
170171
},
171172
/**
172173
* @deprecated in 1.1 in favour of {@link #TRUST_ALL_CERTIFICATES}. Will be deleted in a future version.
@@ -175,15 +176,15 @@ const TrustStrategy = {
175176
console.log("`TRUST_ON_FIRST_USE` has been deprecated as option and will be removed in a future version of " +
176177
"the driver. Please use `TRUST_ALL_CERTIFICATES` instead.");
177178

178-
let tlsOpts = {
179+
const tlsOpts = {
179180
// Because we manually verify the certificate against known_hosts
180181
rejectUnauthorized: false
181182
};
182183

183-
let socket = tls.connect(config.port, config.host, tlsOpts, function () {
184-
var serverCert = socket.getPeerCertificate(/*raw=*/true);
184+
const connectListener = socket => {
185+
const serverCert = socket.getPeerCertificate(/*raw=*/true);
185186

186-
if( !serverCert.raw ) {
187+
if (!serverCert.raw) {
187188
// If `raw` is not available, we're on an old version of NodeJS, and
188189
// the raw cert cannot be accessed (or, at least I couldn't find a way to)
189190
// therefore, we can't generate a SHA512 fingerprint, meaning we can't
@@ -200,10 +201,10 @@ const TrustStrategy = {
200201
const serverId = config.host + ":" + config.port;
201202

202203
loadFingerprint(serverId, knownHostsPath, (knownFingerprint) => {
203-
if( knownFingerprint === serverFingerprint ) {
204+
if (knownFingerprint === serverFingerprint) {
204205
onSuccess();
205-
} else if( knownFingerprint == null ) {
206-
storeFingerprint( serverId, knownHostsPath, serverFingerprint, (err) => {
206+
} else if (knownFingerprint == null) {
207+
storeFingerprint(serverId, knownHostsPath, serverFingerprint, (err) => {
207208
if (err) {
208209
return onFailure(err);
209210
}
@@ -220,32 +221,33 @@ const TrustStrategy = {
220221
"update the file with the new certificate. You can configure which file the driver " +
221222
"should use to store this information by setting `knownHosts` to another path in " +
222223
"your driver configuration - and you can disable encryption there as well using " +
223-
"`encrypted:\"" + ENCRYPTION_OFF + "\"`."))
224+
"`encrypted:\"" + ENCRYPTION_OFF + "\"`."));
224225
}
225226
});
226-
});
227-
socket.on('error', onFailure);
228-
return socket;
227+
};
228+
229+
return connectTlsSocket(config, tlsOpts, connectListener, onFailure);
229230
},
230231

231232
TRUST_ALL_CERTIFICATES: function (config, onSuccess, onFailure) {
232233
const tlsOpts = {
233234
rejectUnauthorized: false
234235
};
235-
const socket = tls.connect(config.port, config.host, tlsOpts, function () {
236+
237+
const connectListener = socket => {
236238
const certificate = socket.getPeerCertificate();
237239
if (isEmptyObjectOrNull(certificate)) {
238240
onFailure(newError("Secure connection was successful but server did not return any valid " +
239-
"certificates. Such connection can not be trusted. If you are just trying " +
240-
" Neo4j out and are not concerned about encryption, simply disable it using " +
241-
"`encrypted=\"" + ENCRYPTION_OFF + "\"` in the driver options. " +
242-
"Socket responded with: " + socket.authorizationError));
241+
"certificates. Such connection can not be trusted. If you are just trying " +
242+
" Neo4j out and are not concerned about encryption, simply disable it using " +
243+
"`encrypted=\"" + ENCRYPTION_OFF + "\"` in the driver options. " +
244+
"Socket responded with: " + socket.authorizationError));
243245
} else {
244246
onSuccess();
245247
}
246-
});
247-
socket.on('error', onFailure);
248-
return socket;
248+
};
249+
250+
return connectTlsSocket(config, tlsOpts, connectListener, onFailure);
249251
}
250252
};
251253

@@ -259,9 +261,7 @@ const TrustStrategy = {
259261
function connect( config, onSuccess, onFailure=(()=>null) ) {
260262
//still allow boolean for backwards compatibility
261263
if (config.encrypted === false || config.encrypted === ENCRYPTION_OFF) {
262-
var conn = net.connect(config.port, config.host, onSuccess);
263-
conn.on('error', onFailure);
264-
return conn;
264+
return connectSocket(config, onSuccess, onFailure);
265265
} else if( TrustStrategy[config.trust]) {
266266
return TrustStrategy[config.trust](config, onSuccess, onFailure);
267267
} else {
@@ -275,6 +275,60 @@ function connect( config, onSuccess, onFailure=(()=>null) ) {
275275
}
276276
}
277277

278+
/**
279+
* Establish insecure connection using `net.Socket`.
280+
* @param {ChannelConfig} config - configuration of this channel.
281+
* @param {function} connectListener - success callback.
282+
* @param {function} failureListener - failure callback.
283+
* @return {*} an instance of `net.Socket`.
284+
*/
285+
function connectSocket(config, connectListener, failureListener) {
286+
const socket = net.connect(config.port, config.host, () => {
287+
// connected! cancel the connection timeout and notify provided callback
288+
socket.setTimeout(0); // remove the timeout
289+
connectListener(socket);
290+
});
291+
socket.on('error', failureListener);
292+
setupConnectionTimeoutIfConfigured(socket, config);
293+
return socket;
294+
}
295+
296+
/**
297+
* Establish secure connection using `tls.TLSSocket`.
298+
* @param {ChannelConfig} config - configuration of this channel.
299+
* @param {object} tlsOpts - options for TLS socket.
300+
* @param {function} connectListener - success callback.
301+
* @param {function} failureListener - failure callback.
302+
* @return {*} an instance of `tls.TLSSocket`.
303+
*/
304+
function connectTlsSocket(config, tlsOpts, connectListener, failureListener) {
305+
const socket = tls.connect(config.port, config.host, tlsOpts, () => {
306+
// connected! cancel the connection timeout and notify provided callback
307+
socket.setTimeout(0);
308+
connectListener(socket);
309+
});
310+
socket.on('error', failureListener);
311+
setupConnectionTimeoutIfConfigured(socket, config);
312+
return socket;
313+
}
314+
315+
/**
316+
* Set connection timeout on the given socket, if configured.
317+
* @param {object} socket - NodeJS net.Socket or tls.TLSSocket.
318+
* @param {ChannelConfig} config - configuration of this channel.
319+
*/
320+
function setupConnectionTimeoutIfConfigured(socket, config) {
321+
const timeout = config.connectionTimeout;
322+
if (timeout) {
323+
socket.on('timeout', () => {
324+
// timeout fired - not connected within configured time. cancel timeout and destroy socket
325+
socket.setTimeout(0);
326+
socket.destroy(newError(`Failed to establish connection in ${timeout}ms`, config.connectionErrorCode));
327+
});
328+
socket.setTimeout(timeout);
329+
}
330+
}
331+
278332
/**
279333
* In a Node.js environment the 'net' module is used
280334
* as transport.

src/v1/internal/ch-websocket.js

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ class WebSocketChannel {
3737
this._pending = [];
3838
this._error = null;
3939
this._handleConnectionError = this._handleConnectionError.bind(this);
40-
this._connectionErrorCode = config.connectionErrorCode;
41-
42-
this._encrypted = config.encrypted;
40+
this._config = config;
4341

4442
let scheme = "ws";
4543
//Allow boolean for backwards compatibility
@@ -67,6 +65,12 @@ class WebSocketChannel {
6765
}
6866
};
6967
this._ws.onopen = function() {
68+
// Connected! Cancel connection timeout
69+
if (self._connectionTimeoutId) {
70+
clearTimeout(self._connectionTimeoutId);
71+
self._connectionTimeoutFired = false;
72+
}
73+
7074
// Drain all pending messages
7175
let pending = self._pending;
7276
self._pending = null;
@@ -76,15 +80,28 @@ class WebSocketChannel {
7680
};
7781
this._ws.onmessage = (event) => {
7882
if( self.onmessage ) {
79-
var b = new HeapBuffer( event.data );
83+
const b = new HeapBuffer(event.data);
8084
self.onmessage( b );
8185
}
8286
};
8387

8488
this._ws.onerror = this._handleConnectionError;
89+
90+
this._connectionTimeoutFired = false;
91+
this._connectionTimeoutId = this._setupConnectionTimeout(config);
8592
}
8693

8794
_handleConnectionError() {
95+
if (this._connectionTimeoutFired) {
96+
// timeout fired - not connected within configured time
97+
this._error = newError(`Failed to establish connection in ${this._config.connectionTimeout}ms`, this._config.connectionErrorCode);
98+
99+
if (this.onerror) {
100+
this.onerror(this._error);
101+
}
102+
return;
103+
}
104+
88105
// onerror triggers on websocket close as well.. don't get me started.
89106
if( this._open ) {
90107
// http://stackoverflow.com/questions/25779831/how-to-catch-websocket-connection-to-ws-xxxnn-failed-connection-closed-be
@@ -94,15 +111,15 @@ class WebSocketChannel {
94111
"the root cause of the failure. Common reasons include the database being " +
95112
"unavailable, using the wrong connection URL or temporary network problems. " +
96113
"If you have enabled encryption, ensure your browser is configured to trust the " +
97-
"certificate Neo4j is configured to use. WebSocket `readyState` is: " + this._ws.readyState, this._connectionErrorCode );
114+
'certificate Neo4j is configured to use. WebSocket `readyState` is: ' + this._ws.readyState, this._config.connectionErrorCode);
98115
if (this.onerror) {
99116
this.onerror(this._error);
100117
}
101118
}
102119
}
103120

104121
isEncrypted() {
105-
return this._encrypted;
122+
return this._config.encrypted;
106123
}
107124

108125
/**
@@ -130,6 +147,22 @@ class WebSocketChannel {
130147
this._ws.close();
131148
this._ws.onclose = cb;
132149
}
150+
151+
/**
152+
* Set connection timeout on the given WebSocket, if configured.
153+
* @return {number} the timeout id or null.
154+
* @private
155+
*/
156+
_setupConnectionTimeout() {
157+
const timeout = this._config.connectionTimeout;
158+
if (timeout) {
159+
return setTimeout(() => {
160+
this._connectionTimeoutFired = true;
161+
this._ws.close();
162+
}, timeout);
163+
}
164+
return null;
165+
}
133166
}
134167

135168
let available = typeof WebSocket !== 'undefined';

0 commit comments

Comments
 (0)