Skip to content

Commit 030a412

Browse files
authored
fix(devtools-connect): re-try connection in case of TLS errors without system CA MONGOSH-1935 (#495)
Recent user reports have made it clear that our attempts to solve TLS errors caused by adding system certificates haven't fully been resolved yet. To address this, we add a generic fallback that attempts to connect a second time without the system certificate store added. This commit also adds TLS errors to the list of fail-fast errors, i.e. errors which should result in a quick connection end because they are unlikely to be resolved by the Node.js driver attempting to re-connect repeatedly on the level of individual connections. This should avoid situations in which timeouts make the connection attempt take twice as long for TLS errors.
1 parent 86b04d5 commit 030a412

File tree

6 files changed

+226
-29
lines changed

6 files changed

+226
-29
lines changed

packages/devtools-connect/src/connect.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,54 @@ describe('devtools connect', function () {
356356
expect(mClient.connect.getCalls()).to.have.lengthOf(1);
357357
expect(result.client).to.equal(mClient);
358358
});
359+
360+
describe('retryable TLS errors', function () {
361+
it('retries TLS errors without system CA integration enabled', async function () {
362+
const uri = 'localhost:27017';
363+
const mClient = new FakeMongoClient();
364+
const mClientType = sinon.stub().returns(mClient);
365+
const failure = new (Error as any)('blahblah', {
366+
cause: Object.assign(new Error('self-signed cert'), {
367+
code: 'SELF_SIGNED_CERT_IN_CHAIN',
368+
}),
369+
});
370+
let earlyFailures = 0;
371+
bus.on('devtools-connect:connect-fail-early', () => earlyFailures++);
372+
373+
mClient.connect = sinon.stub().callsFake(async () => {
374+
await new Promise(setImmediate);
375+
mClient.emit('serverHeartbeatFailed', {
376+
failure,
377+
connectionId: uri,
378+
});
379+
await new Promise(setImmediate);
380+
throw failure;
381+
});
382+
mClient.topology = {
383+
description: {
384+
servers: new Map([['localhost:27017', {}]]),
385+
},
386+
};
387+
388+
try {
389+
await connectMongoClient(uri, defaultOpts, bus, mClientType as any);
390+
expect.fail('missed exception');
391+
} catch (err) {
392+
expect(err).to.equal(failure);
393+
}
394+
395+
expect(mClientType.getCalls()).to.have.lengthOf(2);
396+
expect(
397+
Object.keys(mClientType.getCalls()[0].args[1]).sort()
398+
).to.deep.equal(['allowPartialTrustChain', 'ca', 'lookup']);
399+
expect(
400+
Object.keys(mClientType.getCalls()[1].args[1]).sort()
401+
).to.deep.equal(['allowPartialTrustChain', 'lookup']);
402+
expect((mClient as any).connect.getCalls()).to.have.lengthOf(2);
403+
404+
expect(earlyFailures).to.equal(2);
405+
});
406+
});
359407
});
360408

361409
describe('integration', function () {

packages/devtools-connect/src/connect.ts

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { ConnectLogEmitter } from './index';
22
import dns from 'dns';
3-
import { isFastFailureConnectionError } from './fast-failure-connect';
3+
import {
4+
isFastFailureConnectionError,
5+
isPotentialTLSCertificateError,
6+
} from './fast-failure-connect';
47
import type {
58
MongoClient,
69
MongoClientOptions,
@@ -318,7 +321,7 @@ export class DevtoolsConnectionState {
318321
'productDocsLink' | 'productName' | 'oidc' | 'parentHandle'
319322
>,
320323
logger: ConnectLogEmitter,
321-
ca: string
324+
ca: string | undefined
322325
) {
323326
this.productName = options.productName;
324327
if (options.parentHandle) {
@@ -339,7 +342,7 @@ export class DevtoolsConnectionState {
339342
null,
340343
options
341344
),
342-
...addToOIDCPluginHttpOptions(options.oidc, { ca }),
345+
...addToOIDCPluginHttpOptions(options.oidc, ca ? { ca } : {}),
343346
});
344347
}
345348
}
@@ -397,6 +400,11 @@ export interface DevtoolsConnectOptions extends MongoClientOptions {
397400
applyProxyToOIDC?: boolean;
398401
}
399402

403+
export type ConnectMongoClientResult = {
404+
client: MongoClient;
405+
state: DevtoolsConnectionState;
406+
};
407+
400408
/**
401409
* Connect a MongoClient. If AutoEncryption is requested, first connect without the encryption options and verify that
402410
* the connection is to an enterprise cluster. If not, then error, otherwise close the connection and reconnect with the
@@ -407,35 +415,73 @@ export async function connectMongoClient(
407415
clientOptions: DevtoolsConnectOptions,
408416
logger: ConnectLogEmitter,
409417
MongoClientClass: typeof MongoClient
410-
): Promise<{
411-
client: MongoClient;
412-
state: DevtoolsConnectionState;
413-
}> {
418+
): Promise<ConnectMongoClientResult> {
419+
detectAndLogMissingOptionalDependencies(logger);
420+
421+
const options = { uri, clientOptions, logger, MongoClientClass };
422+
// Connect once with the system certificate store added, and if that fails with
423+
// a TLS error, try again. In theory adding certificates into the certificate store
424+
// should not cause failures, but in practice we have observed some, hence this
425+
// double connection establishment logic.
426+
// We treat TLS errors as fail-fast errors, so in typical situations (even typical
427+
// failure situations) we do not spend an unreasonable amount of time in the first
428+
// connection attempt.
429+
try {
430+
return await connectMongoClientImpl({ ...options, useSystemCA: true });
431+
} catch (error: unknown) {
432+
if (isPotentialTLSCertificateError(error)) {
433+
logger.emit('devtools-connect:retry-after-tls-error', {
434+
error: String(error),
435+
});
436+
try {
437+
return await connectMongoClientImpl({ ...options, useSystemCA: false });
438+
} catch {}
439+
}
440+
throw error;
441+
}
442+
}
443+
444+
async function connectMongoClientImpl({
445+
uri,
446+
clientOptions,
447+
logger,
448+
MongoClientClass,
449+
useSystemCA,
450+
}: {
451+
uri: string;
452+
clientOptions: DevtoolsConnectOptions;
453+
logger: ConnectLogEmitter;
454+
MongoClientClass: typeof MongoClient;
455+
useSystemCA: boolean;
456+
}): Promise<ConnectMongoClientResult> {
414457
const cleanupOnClientClose: (() => void | Promise<void>)[] = [];
415458
const runClose = async () => {
416459
let item: (() => void | Promise<void>) | undefined;
417460
while ((item = cleanupOnClientClose.shift())) await item();
418461
};
419-
detectAndLogMissingOptionalDependencies(logger);
420462

463+
let ca: string | undefined;
421464
try {
422-
const {
423-
ca,
424-
asyncFallbackError,
425-
systemCertsError,
426-
systemCACount,
427-
messages,
428-
} = await systemCA({
429-
ca: clientOptions.ca,
430-
tlsCAFile:
431-
clientOptions.tlsCAFile || getConnectionStringParam(uri, 'tlsCAFile'),
432-
});
433-
logger.emit('devtools-connect:used-system-ca', {
434-
caCount: systemCACount,
435-
asyncFallbackError,
436-
systemCertsError,
437-
messages,
438-
});
465+
if (useSystemCA) {
466+
const {
467+
ca: caWithSystemCerts,
468+
asyncFallbackError,
469+
systemCertsError,
470+
systemCACount,
471+
messages,
472+
} = await systemCA({
473+
ca: clientOptions.ca,
474+
tlsCAFile:
475+
clientOptions.tlsCAFile || getConnectionStringParam(uri, 'tlsCAFile'),
476+
});
477+
logger.emit('devtools-connect:used-system-ca', {
478+
caCount: systemCACount,
479+
asyncFallbackError,
480+
systemCertsError,
481+
messages,
482+
});
483+
ca = caWithSystemCerts;
484+
}
439485

440486
// Create a proxy agent, if requested. `useOrCreateAgent()` takes a target argument
441487
// that can be used to select a proxy for a specific procotol or host;
@@ -448,7 +494,7 @@ export async function connectMongoClient(
448494
? clientOptions.proxy
449495
: {
450496
...(clientOptions.proxy as DevtoolsProxyOptions),
451-
ca,
497+
...(ca ? { ca } : {}),
452498
},
453499
clientOptions.applyProxyToOIDC ? undefined : 'mongodb://'
454500
);
@@ -498,7 +544,8 @@ export async function connectMongoClient(
498544
{},
499545
clientOptions,
500546
shouldAddOidcCallbacks ? state.oidcPlugin.mongoClientOptions : {},
501-
{ ca, allowPartialTrustChain: true }
547+
{ allowPartialTrustChain: true },
548+
ca ? { ca } : {}
502549
);
503550

504551
// Adopt dns result order changes with Node v18 that affected the VSCode extension VSCODE-458.
Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// It probably makes sense to put this into its own package/repository once
22
// other tools start using it.
33

4-
export function isFastFailureConnectionError(error: Error): boolean {
4+
function isFastFailureConnectionSingleError(
5+
error: Error & { code?: string }
6+
): boolean {
57
switch (error.name) {
68
case 'MongoNetworkError':
79
return /\b(ECONNREFUSED|ENOTFOUND|ENETUNREACH|EINVAL)\b/.test(
@@ -10,6 +12,81 @@ export function isFastFailureConnectionError(error: Error): boolean {
1012
case 'MongoError':
1113
return /The apiVersion parameter is required/.test(error.message);
1214
default:
13-
return false;
15+
return (
16+
['ECONNREFUSED', 'ENOTFOUND', 'ENETUNREACH', 'EINVAL'].includes(
17+
error.code ?? ''
18+
) || isPotentialTLSCertificateError(error)
19+
);
1420
}
1521
}
22+
23+
export const isFastFailureConnectionError = handleNestedErrors(
24+
isFastFailureConnectionSingleError
25+
);
26+
27+
function isPotentialTLSCertificateSingleError(
28+
error: Error & { code?: string }
29+
): boolean {
30+
// https://nodejs.org/api/tls.html#x509-certificate-error-codes
31+
return [
32+
'UNABLE_TO_GET_ISSUER_CERT',
33+
'UNABLE_TO_GET_CRL',
34+
'UNABLE_TO_DECRYPT_CERT_SIGNATURE',
35+
'UNABLE_TO_DECRYPT_CRL_SIGNATURE',
36+
'UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY',
37+
'CERT_SIGNATURE_FAILURE',
38+
'CRL_SIGNATURE_FAILURE',
39+
'CERT_NOT_YET_VALID',
40+
'CERT_HAS_EXPIRED',
41+
'CRL_NOT_YET_VALID',
42+
'CRL_HAS_EXPIRED',
43+
'ERROR_IN_CERT_NOT_BEFORE_FIELD',
44+
'ERROR_IN_CERT_NOT_AFTER_FIELD',
45+
'ERROR_IN_CRL_LAST_UPDATE_FIELD',
46+
'ERROR_IN_CRL_NEXT_UPDATE_FIELD',
47+
'OUT_OF_MEM',
48+
'DEPTH_ZERO_SELF_SIGNED_CERT',
49+
'SELF_SIGNED_CERT_IN_CHAIN',
50+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
51+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
52+
'CERT_CHAIN_TOO_LONG',
53+
'CERT_REVOKED',
54+
'INVALID_CA',
55+
'PATH_LENGTH_EXCEEDED',
56+
'INVALID_PURPOSE',
57+
'CERT_UNTRUSTED',
58+
'CERT_REJECTED',
59+
'HOSTNAME_MISMATCH',
60+
].includes(error.code ?? '');
61+
}
62+
63+
export const isPotentialTLSCertificateError = handleNestedErrors(
64+
isPotentialTLSCertificateSingleError
65+
);
66+
67+
// Convenience wrapper that ensures that the given error is an `Error` instance
68+
// and that handles nested errors (.cause/AggregateError) as well
69+
function handleNestedErrors(
70+
fn: (err: Error & { code?: string }) => boolean
71+
): (err: unknown) => boolean {
72+
const checker = (err: unknown): boolean => {
73+
if (
74+
Object.prototype.toString.call(err).toLowerCase() !== '[object error]'
75+
) {
76+
return checker(new Error(String(err)));
77+
}
78+
if (!err || typeof err !== 'object') return false; // Make TS happy
79+
if ('cause' in err && checker(err.cause)) {
80+
return true; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
81+
}
82+
if (
83+
'errors' in err &&
84+
Array.isArray(err.errors) &&
85+
err.errors.some((err) => checker(err))
86+
) {
87+
return true; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError
88+
}
89+
return fn(err as Error);
90+
};
91+
return checker;
92+
}

packages/devtools-connect/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type {
55
DevtoolsConnectionState,
66
DevtoolsProxyOptions,
77
AgentWithInitialize,
8+
ConnectMongoClientResult,
89
} from './connect';
910
export { hookLogger } from './log-hook';
1011
export { oidcServerRequestHandler } from './oidc/handler';

packages/devtools-connect/src/log-hook.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ConnectMissingOptionalDependencyEvent,
99
ConnectUsedSystemCAEvent,
1010
ConnectLogEmitter,
11+
ConnectRetryAfterTLSErrorEvent,
1112
} from './types';
1213

1314
import { hookLoggerToMongoLogWriter as oidcHookLogger } from '@mongodb-js/oidc-plugin';
@@ -174,4 +175,19 @@ export function hookLogger(
174175
);
175176
}
176177
);
178+
179+
emitter.on(
180+
'devtools-connect:retry-after-tls-error',
181+
function (ev: ConnectRetryAfterTLSErrorEvent) {
182+
log.info(
183+
'DEVTOOLS-CONNECT',
184+
mongoLogId(1_000_000_054),
185+
`${contextPrefix}-connect`,
186+
'Restarting connection attempt after TLS error',
187+
{
188+
error: ev.error,
189+
}
190+
);
191+
}
192+
);
177193
}

packages/devtools-connect/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export interface ConnectUsedSystemCAEvent {
5858
messages: string[];
5959
}
6060

61+
export interface ConnectRetryAfterTLSErrorEvent {
62+
error: string;
63+
}
64+
6165
export interface ConnectEventMap
6266
extends MongoDBOIDCLogEventsMap,
6367
ProxyEventMap {
@@ -93,6 +97,10 @@ export interface ConnectEventMap
9397
) => void;
9498
/** Signals that the list of system certificates has been loaded and used for connecting. */
9599
'devtools-connect:used-system-ca': (ev: ConnectUsedSystemCAEvent) => void;
100+
/** Signals that a connection attempt was restarted after a TLS error */
101+
'devtools-connect:retry-after-tls-error': (
102+
ev: ConnectRetryAfterTLSErrorEvent
103+
) => void;
96104
}
97105

98106
export type ConnectEventArgs<K extends keyof ConnectEventMap> =

0 commit comments

Comments
 (0)