This repository has been archived by the owner on Feb 21, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathconnect.ts
399 lines (374 loc) · 16 KB
/
connect.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
import type { ConnectLogEmitter } from './index';
import dns from 'dns';
import { isFastFailureConnectionError } from './fast-failure-connect';
import type {
MongoClient,
MongoClientOptions,
ServerHeartbeatFailedEvent,
ServerHeartbeatSucceededEvent,
TopologyDescription
} from 'mongodb';
import type { ConnectDnsResolutionDetail, ConnectEventArgs, ConnectEventMap } from './types';
import { systemCertsAsync, Options as SystemCAOptions } from 'system-ca';
import type { MongoDBOIDCPlugin, MongoDBOIDCPluginOptions } from '@mongodb-js/oidc-plugin';
import { createMongoDBOIDCPlugin } from '@mongodb-js/oidc-plugin';
import merge from 'lodash.merge';
import { oidcServerRequestHandler } from './oidc/handler';
import { StateShareClient, StateShareServer } from './ipc-rpc-state-share';
import ConnectionString, { CommaAndColonSeparatedRecord } from 'mongodb-connection-string-url';
import EventEmitter from 'events';
function isAtlas(str: string): boolean {
try {
const { hosts } = new ConnectionString(str);
return hosts.every(host => /(^|\.)mongodb.net(:|$)/.test(host));
} catch { return false; }
}
export class MongoAutoencryptionUnavailable extends Error {
constructor() {
super('Automatic encryption is only available with Atlas and MongoDB Enterprise');
}
}
/**
* Takes an unconnected MongoClient and connects it, but fails fast for certain
* errors.
*/
async function connectWithFailFast(uri: string, client: MongoClient, logger: ConnectLogEmitter): Promise<void> {
const failedConnections = new Map<string, Error>();
let failEarlyClosePromise: Promise<void> | null = null;
logger.emit('devtools-connect:connect-attempt-initialized', {
uri,
driver: client.options.metadata.driver,
// eslint-disable-next-line @typescript-eslint/no-var-requires
devtoolsConnectVersion: require('../package.json').version,
host: client.options.srvHost ?? client.options.hosts.join(',')
});
const heartbeatFailureListener = ({ failure, connectionId }: ServerHeartbeatFailedEvent) => {
const topologyDescription: TopologyDescription | undefined = (client as any).topology?.description;
const servers = topologyDescription?.servers;
const isFailFast = isFastFailureConnectionError(failure);
const isKnownServer = !!servers?.has(connectionId);
logger.emit('devtools-connect:connect-heartbeat-failure', {
connectionId,
failure,
isFailFast,
isKnownServer
});
if (!isKnownServer) {
return;
}
if (isFailFast && servers) {
failedConnections.set(connectionId, failure);
if ([...servers.keys()].every(server => failedConnections.has(server))) {
logger.emit('devtools-connect:connect-fail-early');
// Setting this variable indicates that we are failing early.
failEarlyClosePromise = client.close();
}
}
};
const heartbeatSucceededListener = ({ connectionId }: ServerHeartbeatSucceededEvent) => {
logger.emit('devtools-connect:connect-heartbeat-succeeded', { connectionId });
failedConnections.delete(connectionId);
};
client.addListener('serverHeartbeatFailed', heartbeatFailureListener);
client.addListener('serverHeartbeatSucceeded', heartbeatSucceededListener);
try {
await client.connect();
} catch (error: unknown) {
let connectErr = error;
if (failEarlyClosePromise !== null) {
await failEarlyClosePromise;
connectErr = failedConnections.values().next().value; // Just use the first failure.
}
if (
typeof connectErr === 'object' &&
connectErr?.constructor.name === 'MongoServerSelectionError' &&
isAtlas(uri)
) {
(connectErr as Error).message = `${(connectErr as Error).message}. It looks like this is a MongoDB Atlas cluster. Please ensure that your Network Access List allows connections from your IP.`;
}
throw connectErr;
} finally {
client.removeListener('serverHeartbeatFailed', heartbeatFailureListener);
client.removeListener('serverHeartbeatSucceeded', heartbeatSucceededListener);
logger.emit('devtools-connect:connect-attempt-finished', {
cryptSharedLibVersionInfo: (client as any)?.autoEncrypter?.cryptSharedLibVersionInfo
});
}
}
let resolveDnsHelpers: {
resolve: typeof import('resolve-mongodb-srv'),
osDns: typeof import('os-dns-native')
} | undefined;
async function resolveMongodbSrv(uri: string, logger: ConnectLogEmitter): Promise<string> {
const resolutionDetails: ConnectDnsResolutionDetail[] = [];
if (uri.startsWith('mongodb+srv://')) {
try {
resolveDnsHelpers ??= {
resolve: require('resolve-mongodb-srv'),
osDns: require('os-dns-native')
};
} catch (error: any) {
logger.emit('devtools-connect:resolve-srv-error', {
from: '', error, duringLoad: true, resolutionDetails
});
}
if (resolveDnsHelpers !== undefined) {
try {
const {
wasNativelyLookedUp,
withNodeFallback: { resolveSrv, resolveTxt }
} = resolveDnsHelpers.osDns;
const resolved = await resolveDnsHelpers.resolve(uri, {
dns: {
resolveSrv(hostname: string, cb: Parameters<typeof resolveSrv>[1]) {
resolveSrv(hostname, (...args: Parameters<Parameters<typeof resolveSrv>[1]>) => {
resolutionDetails.push({
query: 'SRV', hostname, error: args[0]?.message, wasNativelyLookedUp: wasNativelyLookedUp(args[1])
});
// eslint-disable-next-line n/no-callback-literal
cb(...args);
});
},
resolveTxt(hostname: string, cb: Parameters<typeof resolveTxt>[1]) {
resolveTxt(hostname, (...args: Parameters<Parameters<typeof resolveTxt>[1]>) => {
resolutionDetails.push({
query: 'TXT', hostname, error: args[0]?.message, wasNativelyLookedUp: wasNativelyLookedUp(args[1])
});
// eslint-disable-next-line n/no-callback-literal
cb(...args);
});
}
}
});
logger.emit('devtools-connect:resolve-srv-succeeded', { from: uri, to: resolved, resolutionDetails });
return resolved;
} catch (error: any) {
logger.emit('devtools-connect:resolve-srv-error', { from: uri, error, duringLoad: false, resolutionDetails });
throw error;
}
}
}
return uri;
}
function detectAndLogMissingOptionalDependencies(logger: ConnectLogEmitter) {
// These need to be literal require('string') calls for bundling purposes.
try {
require('socks');
} catch (error: any) {
logger.emit('devtools-connect:missing-optional-dependency', { name: 'socks', error });
}
try {
require('mongodb-client-encryption');
} catch (error: any) {
logger.emit('devtools-connect:missing-optional-dependency', { name: 'mongodb-client-encryption', error });
}
try {
require('os-dns-native');
} catch (error: any) {
logger.emit('devtools-connect:missing-optional-dependency', { name: 'os-dns-native', error });
}
try {
require('resolve-mongodb-srv');
} catch (error: any) {
logger.emit('devtools-connect:missing-optional-dependency', { name: 'resolve-mongodb-srv', error });
}
try {
require('kerberos');
} catch (error: any) {
logger.emit('devtools-connect:missing-optional-dependency', { name: 'kerberos', error });
}
}
// Wrapper for all state that a devtools application may want to share
// between MongoClient instances. Currently, this is only the OIDC state.
// There are two ways of sharing this state:
// - When re-used within the same process/address space, it can be passed
// to `connectMongoClient()` as `parentState` directly.
// - When re-used across processes, an RPC server can be used over an IPC
// channel by calling `.getStateShareServer()`, which returns a string
// that can then be passed to `connectMongoClient()` as `parentHandle`
// and which should be considered secret since it contains auth information
// for that RPC server.
export class DevtoolsConnectionState {
public oidcPlugin: MongoDBOIDCPlugin;
public productName: string;
private stateShareClient: StateShareClient | null = null;
private stateShareServer: StateShareServer | null = null;
constructor(options: Pick<DevtoolsConnectOptions, 'productDocsLink' | 'productName' | 'oidc' | 'parentHandle'>, logger: ConnectLogEmitter) {
this.productName = options.productName;
if (options.parentHandle) {
this.stateShareClient = new StateShareClient(options.parentHandle);
this.oidcPlugin = this.stateShareClient.oidcPlugin;
} else {
// Create a separate logger instance for the plugin and "copy" events over
// to the main logger instance, so that when we attach listeners to the plugins,
// they are only triggered for events from that specific plugin instance
// (and not other OIDCPlugin instances that might be running on the same logger).
const proxyingLogger = new EventEmitter();
proxyingLogger.setMaxListeners(Infinity);
proxyingLogger.emit = function<K extends keyof ConnectEventMap>(event: K, ...args: ConnectEventArgs<K>) {
logger.emit(event, ...args);
return EventEmitter.prototype.emit.call(this, event, ...args);
};
this.oidcPlugin = createMongoDBOIDCPlugin({
...options.oidc,
logger: proxyingLogger,
redirectServerRequestHandler: oidcServerRequestHandler.bind(null, options)
});
}
}
async getStateShareServer(): Promise<string> {
this.stateShareServer ??= await StateShareServer.create(this);
return this.stateShareServer.handle;
}
async destroy(): Promise<void> {
await this.stateShareServer?.close();
await this.oidcPlugin?.destroy();
}
}
export interface DevtoolsConnectOptions extends MongoClientOptions {
/**
* Whether to read the system certificate store and pass that as the `ca` option
* to the driver for certificate validation.
*/
useSystemCA?: boolean;
/**
* An URL that refers to the documentation for the current product.
*/
productDocsLink: string;
/**
* A human-readable name for the current product (e.g. "MongoDB Compass").
*/
productName: string;
/**
* A set of options to pass when creating the OIDC plugin. Ignored if `parentState` is set.
*/
oidc?: Omit<MongoDBOIDCPluginOptions, 'logger' | 'redirectServerRequestHandler'>;
/**
* A `DevtoolsConnectionState` object that refers to the state resulting from another
* `connectMongoClient()` call.
*/
parentState?: DevtoolsConnectionState;
/**
* Similar to `parentState`, an opaque handle returned from `createShareStateServer()`
* may be used to share state from another `DevtoolsConnectionState` instance, possibly
* residing in another process. This handle should generally be considered a secret.
*
* In this case, the application needs to ensure that the lifetime of the top-level state
* extends beyond the lifetime(s) of the respective dependent state instance(s).
*/
parentHandle?: string;
}
/**
* Connect a MongoClient. If AutoEncryption is requested, first connect without the encryption options and verify that
* the connection is to an enterprise cluster. If not, then error, otherwise close the connection and reconnect with the
* options the user initially specified. Provide the client class as an additional argument in order to test.
*/
export async function connectMongoClient(
uri: string,
clientOptions: DevtoolsConnectOptions,
logger: ConnectLogEmitter,
MongoClientClass: typeof MongoClient): Promise<{
client: MongoClient,
state: DevtoolsConnectionState
}> {
detectAndLogMissingOptionalDependencies(logger);
// If PROVIDER_NAME was specified to the MongoClient options, adding callbacks would conflict
// with that; we should omit them so that e.g. mongosh users can leverage the non-human OIDC
// auth flows by specifying PROVIDER_NAME.
const shouldAddOidcCallbacks = isHumanOidcFlow(uri, clientOptions);
const state = clientOptions.parentState ?? new DevtoolsConnectionState(clientOptions, logger);
const mongoClientOptions: MongoClientOptions & Partial<DevtoolsConnectOptions> =
merge({}, clientOptions, shouldAddOidcCallbacks ? state.oidcPlugin.mongoClientOptions : {});
// Adopt dns result order changes with Node v18 that affected the VSCode extension VSCODE-458.
// Refs https://github.com/microsoft/vscode/issues/189805
mongoClientOptions.lookup = (hostname, options, callback) => {
return dns.lookup(hostname, { verbatim: false, ...options }, callback);
};
if (clientOptions.useSystemCA) {
const systemCAOpts: SystemCAOptions = { includeNodeCertificates: true };
const ca = await systemCertsAsync(systemCAOpts);
logger.emit('devtools-connect:used-system-ca', {
caCount: ca.length,
asyncFallbackError: systemCAOpts.asyncFallbackError
});
mongoClientOptions.ca = ca.join('\n');
}
delete mongoClientOptions.useSystemCA;
delete mongoClientOptions.productDocsLink;
delete mongoClientOptions.productName;
delete mongoClientOptions.oidc;
delete mongoClientOptions.parentState;
delete mongoClientOptions.parentHandle;
if (mongoClientOptions.autoEncryption !== undefined &&
!mongoClientOptions.autoEncryption.bypassAutoEncryption &&
!mongoClientOptions.autoEncryption.bypassQueryAnalysis) {
// connect first without autoEncryption and serverApi options.
const optionsWithoutFLE = { ...mongoClientOptions };
delete optionsWithoutFLE.autoEncryption;
delete optionsWithoutFLE.serverApi;
const client = new MongoClientClass(uri, optionsWithoutFLE);
closeMongoClientWhenAuthFails(state, client);
await connectWithFailFast(uri, client, logger);
const buildInfo = await client.db('admin').admin().command({ buildInfo: 1 });
await client.close();
if (
!(buildInfo.modules?.includes('enterprise')) &&
!(buildInfo.gitVersion?.match(/enterprise/))
) {
throw new MongoAutoencryptionUnavailable();
}
}
uri = await resolveMongodbSrv(uri, logger);
const client = new MongoClientClass(uri, mongoClientOptions);
closeMongoClientWhenAuthFails(state, client);
await connectWithFailFast(uri, client, logger);
if ((client as any).autoEncrypter) {
// Enable Devtools-specific CSFLE result decoration.
((client as any).autoEncrypter)[Symbol.for('@@mdb.decorateDecryptionResult')] = true;
}
return { client, state };
}
export function isHumanOidcFlow(uri: string, clientOptions: MongoClientOptions): boolean {
if (
(clientOptions.authMechanism && clientOptions.authMechanism !== 'MONGODB-OIDC') ||
clientOptions.authMechanismProperties?.PROVIDER_NAME
) {
return false;
}
let cs: ConnectionString;
try {
cs = new ConnectionString(uri, { looseValidation: true });
} catch {
return false;
}
const sp = cs.typedSearchParams<MongoClientOptions>();
const authMechanism = clientOptions.authMechanism ?? sp.get('authMechanism');
return authMechanism === 'MONGODB-OIDC' && !new CommaAndColonSeparatedRecord(
sp.get('authMechanismProperties')
).get('PROVIDER_NAME');
}
function closeMongoClientWhenAuthFails(
state: DevtoolsConnectionState,
client: MongoClient
): void {
// First, make sure that the 'close' event is emitted on the client,
// see also the comments in https://jira.mongodb.org/browse/NODE-5155.
const originalClose = client.close;
client.close = async function(...args) {
let closeEmitted = false;
const onClose = () => closeEmitted = true;
this.on('close', onClose);
const result = await originalClose.call(this, ...args);
this.off('close', onClose);
if (!closeEmitted) {
this.emit('close');
}
return result;
};
// Close the client when the OIDC plugin says that authentication failed
// (notably, this also happens when that failure comes from another
// client using the same `state` instance).
const onOIDCAuthFailed = () => client.close().catch(() => {});
state.oidcPlugin.logger.once('mongodb-oidc-plugin:auth-failed', onOIDCAuthFailed);
client.once('close', () => state.oidcPlugin.logger.off?.('mongodb-oidc-plugin:auth-failed', onOIDCAuthFailed));
}