Skip to content

Commit 456a70c

Browse files
committed
WIP
1 parent a76bdc6 commit 456a70c

File tree

11 files changed

+319
-127
lines changed

11 files changed

+319
-127
lines changed

packages/devtools-connect/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"license": "Apache-2.0",
4949
"dependencies": {
5050
"@mongodb-js/oidc-http-server-pages": "1.1.2",
51+
"@mongodb-js/devtools-proxy-support": "^0.1.0",
5152
"lodash.merge": "^4.6.2",
5253
"mongodb-connection-string-url": "^3.0.0",
5354
"socks": "^2.7.3",

packages/devtools-connect/src/connect.ts

Lines changed: 197 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,11 @@ import type {
88
ServerHeartbeatSucceededEvent,
99
TopologyDescription,
1010
} from 'mongodb';
11-
import type {
12-
ConnectDnsResolutionDetail,
13-
ConnectEventArgs,
14-
ConnectEventMap,
15-
} from './types';
11+
import type { ConnectDnsResolutionDetail } from './types';
1612
import { systemCertsAsync } from 'system-ca';
1713
import type { Options as SystemCAOptions } from 'system-ca';
1814
import type {
15+
HttpOptions as OIDCHTTPOptions,
1916
MongoDBOIDCPlugin,
2017
MongoDBOIDCPluginOptions,
2118
} from '@mongodb-js/oidc-plugin';
@@ -26,7 +23,15 @@ import { StateShareClient, StateShareServer } from './ipc-rpc-state-share';
2623
import ConnectionString, {
2724
CommaAndColonSeparatedRecord,
2825
} from 'mongodb-connection-string-url';
29-
import EventEmitter from 'events';
26+
import { EventEmitter } from 'events';
27+
import {
28+
createSocks5Tunnel,
29+
DevtoolsProxyOptions,
30+
AgentWithInitialize,
31+
useOrCreateAgent,
32+
Tunnel,
33+
} from '@mongodb-js/devtools-proxy-support';
34+
export type { DevtoolsProxyOptions, AgentWithInitialize };
3035

3136
function isAtlas(str: string): boolean {
3237
try {
@@ -267,6 +272,30 @@ function detectAndLogMissingOptionalDependencies(logger: ConnectLogEmitter) {
267272
}
268273
}
269274

275+
// Override 'from.emit' so that all events also end up being emitted on 'to'
276+
function copyEventEmitterEvents<M>(
277+
from: {
278+
emit: <K extends string & keyof M>(
279+
event: K,
280+
...args: M[K] extends (...args: infer P) => any ? P : never
281+
) => void;
282+
},
283+
to: {
284+
emit: <K extends string & keyof M>(
285+
event: K,
286+
...args: M[K] extends (...args: infer P) => any ? P : never
287+
) => void;
288+
}
289+
) {
290+
from.emit = function <K extends string & keyof M>(
291+
event: K,
292+
...args: M[K] extends (...args: infer P) => any ? P : never
293+
) {
294+
to.emit(event, ...args);
295+
return EventEmitter.prototype.emit.call(this, event, ...args);
296+
};
297+
}
298+
270299
// Wrapper for all state that a devtools application may want to share
271300
// between MongoClient instances. Currently, this is only the OIDC state.
272301
// There are two ways of sharing this state:
@@ -303,13 +332,7 @@ export class DevtoolsConnectionState {
303332
// (and not other OIDCPlugin instances that might be running on the same logger).
304333
const proxyingLogger = new EventEmitter();
305334
proxyingLogger.setMaxListeners(Infinity);
306-
proxyingLogger.emit = function <K extends keyof ConnectEventMap>(
307-
event: K,
308-
...args: ConnectEventArgs<K>
309-
) {
310-
logger.emit(event, ...args);
311-
return EventEmitter.prototype.emit.call(this, event, ...args);
312-
};
335+
copyEventEmitterEvents(proxyingLogger, logger);
313336
this.oidcPlugin = createMongoDBOIDCPlugin({
314337
...options.oidc,
315338
logger: proxyingLogger,
@@ -318,7 +341,7 @@ export class DevtoolsConnectionState {
318341
options
319342
),
320343
...(systemCA
321-
? addCAToOIDCPluginHttpOptions(options.oidc, systemCA)
344+
? addToOIDCPluginHttpOptions(options.oidc, { ca: systemCA })
322345
: {}),
323346
});
324347
}
@@ -370,6 +393,16 @@ export interface DevtoolsConnectOptions extends MongoClientOptions {
370393
* extends beyond the lifetime(s) of the respective dependent state instance(s).
371394
*/
372395
parentHandle?: string;
396+
/**
397+
* Proxy options or an existing proxy Agent instance that can be shared. These are applied to
398+
* both database cluster traffic and, optionally, OIDC HTTP traffic.
399+
*/
400+
proxy?: DevtoolsProxyOptions | AgentWithInitialize;
401+
/**
402+
* Whether the proxy specified in `.proxy` should be applied to OIDC HTTP traffic as well.
403+
* An explicitly specified `agent` in the options for the OIDC plugin will always take precedence.
404+
*/
405+
applyProxyToOIDC?: boolean;
373406
}
374407

375408
/**
@@ -386,82 +419,159 @@ export async function connectMongoClient(
386419
client: MongoClient;
387420
state: DevtoolsConnectionState;
388421
}> {
422+
const cleanupOnClientClose: (() => void | Promise<void>)[] = [];
423+
const runClose = async () => {
424+
let item: (() => void | Promise<void>) | undefined;
425+
while ((item = cleanupOnClientClose.shift())) await item();
426+
};
389427
detectAndLogMissingOptionalDependencies(logger);
390428

391-
let systemCA: string | undefined;
392-
if (clientOptions.useSystemCA) {
393-
const systemCAOpts: SystemCAOptions = { includeNodeCertificates: true };
394-
const ca = await systemCertsAsync(systemCAOpts);
395-
logger.emit('devtools-connect:used-system-ca', {
396-
caCount: ca.length,
397-
asyncFallbackError: systemCAOpts.asyncFallbackError,
398-
});
399-
systemCA = ca.join('\n');
400-
}
429+
try {
430+
let systemCA: string | undefined;
431+
// TODO(COMPASS-8077): Remove this option and enable it by default
432+
if (clientOptions.useSystemCA) {
433+
const systemCAOpts: SystemCAOptions = { includeNodeCertificates: true };
434+
const ca = await systemCertsAsync(systemCAOpts);
435+
logger.emit('devtools-connect:used-system-ca', {
436+
caCount: ca.length,
437+
asyncFallbackError: systemCAOpts.asyncFallbackError,
438+
});
439+
systemCA = ca.join('\n');
440+
}
401441

402-
// If PROVIDER_NAME was specified to the MongoClient options, adding callbacks would conflict
403-
// with that; we should omit them so that e.g. mongosh users can leverage the non-human OIDC
404-
// auth flows by specifying PROVIDER_NAME.
405-
const shouldAddOidcCallbacks = isHumanOidcFlow(uri, clientOptions);
406-
const state =
407-
clientOptions.parentState ??
408-
new DevtoolsConnectionState(clientOptions, logger, systemCA);
409-
const mongoClientOptions: MongoClientOptions &
410-
Partial<DevtoolsConnectOptions> = merge(
411-
{},
412-
clientOptions,
413-
shouldAddOidcCallbacks ? state.oidcPlugin.mongoClientOptions : {},
414-
systemCA ? { ca: systemCA } : {}
415-
);
442+
// Create a proxy agent, if requested. `useOrCreateAgent()` takes a target argument
443+
// that can be used to select a proxy for a specific procotol or host;
444+
// here we specify 'mongodb://' if we only intend to use the proxy for database
445+
// connectivity.
446+
const proxyAgent =
447+
clientOptions.proxy &&
448+
useOrCreateAgent(
449+
'createConnection' in clientOptions.proxy
450+
? clientOptions.proxy
451+
: {
452+
...(clientOptions.proxy as DevtoolsProxyOptions),
453+
// TODO(COMPASS-8077): Always use explicit CA from either system CA or
454+
// tlsCAFile option, including one potentially coming from the command line
455+
...(systemCA ? { ca: systemCA } : {}),
456+
},
457+
clientOptions.applyProxyToOIDC ? undefined : 'mongodb://'
458+
);
459+
cleanupOnClientClose.push(() => proxyAgent?.destroy());
416460

417-
// Adopt dns result order changes with Node v18 that affected the VSCode extension VSCODE-458.
418-
// Refs https://github.com/microsoft/vscode/issues/189805
419-
mongoClientOptions.lookup = (hostname, options, callback) => {
420-
return dns.lookup(hostname, { verbatim: false, ...options }, callback);
421-
};
461+
if (clientOptions.applyProxyToOIDC) {
462+
clientOptions.oidc = {
463+
...clientOptions.oidc,
464+
...addToOIDCPluginHttpOptions(clientOptions.oidc, {
465+
agent: proxyAgent,
466+
}),
467+
};
468+
}
469+
470+
let tunnel: Tunnel | undefined;
471+
if (proxyAgent && !hasProxyHostOption(uri, clientOptions)) {
472+
tunnel = createSocks5Tunnel(proxyAgent, 'generate-credentials');
473+
cleanupOnClientClose.push(() => tunnel?.close());
474+
}
475+
for (const proxyLogger of new Set([tunnel?.logger, proxyAgent?.logger])) {
476+
if (proxyLogger) {
477+
copyEventEmitterEvents(proxyLogger, logger);
478+
}
479+
}
480+
if (tunnel) {
481+
// Should happen after attaching loggers
482+
await tunnel?.listen();
483+
clientOptions = {
484+
...clientOptions,
485+
...tunnel?.config,
486+
};
487+
}
422488

423-
delete mongoClientOptions.useSystemCA;
424-
delete mongoClientOptions.productDocsLink;
425-
delete mongoClientOptions.productName;
426-
delete mongoClientOptions.oidc;
427-
delete mongoClientOptions.parentState;
428-
delete mongoClientOptions.parentHandle;
489+
// If PROVIDER_NAME was specified to the MongoClient options, adding callbacks would conflict
490+
// with that; we should omit them so that e.g. mongosh users can leverage the non-human OIDC
491+
// auth flows by specifying PROVIDER_NAME.
492+
const shouldAddOidcCallbacks = isHumanOidcFlow(uri, clientOptions);
493+
const state =
494+
clientOptions.parentState ??
495+
new DevtoolsConnectionState(clientOptions, logger, systemCA);
496+
const mongoClientOptions: MongoClientOptions &
497+
Partial<DevtoolsConnectOptions> = merge(
498+
{},
499+
clientOptions,
500+
shouldAddOidcCallbacks ? state.oidcPlugin.mongoClientOptions : {},
501+
systemCA ? { ca: systemCA } : {}
502+
);
503+
504+
// Adopt dns result order changes with Node v18 that affected the VSCode extension VSCODE-458.
505+
// Refs https://github.com/microsoft/vscode/issues/189805
506+
mongoClientOptions.lookup = (hostname, options, callback) => {
507+
return dns.lookup(hostname, { verbatim: false, ...options }, callback);
508+
};
509+
510+
delete mongoClientOptions.useSystemCA;
511+
delete mongoClientOptions.productDocsLink;
512+
delete mongoClientOptions.productName;
513+
delete mongoClientOptions.oidc;
514+
delete mongoClientOptions.parentState;
515+
delete mongoClientOptions.parentHandle;
516+
delete mongoClientOptions.proxy;
517+
delete mongoClientOptions.applyProxyToOIDC;
429518

430-
if (
431-
mongoClientOptions.autoEncryption !== undefined &&
432-
!mongoClientOptions.autoEncryption.bypassAutoEncryption &&
433-
!mongoClientOptions.autoEncryption.bypassQueryAnalysis
434-
) {
435-
// connect first without autoEncryption and serverApi options.
436-
const optionsWithoutFLE = { ...mongoClientOptions };
437-
delete optionsWithoutFLE.autoEncryption;
438-
delete optionsWithoutFLE.serverApi;
439-
const client = new MongoClientClass(uri, optionsWithoutFLE);
440-
closeMongoClientWhenAuthFails(state, client);
441-
await connectWithFailFast(uri, client, logger);
442-
const buildInfo = await client
443-
.db('admin')
444-
.admin()
445-
.command({ buildInfo: 1 });
446-
await client.close();
447519
if (
448-
!buildInfo.modules?.includes('enterprise') &&
449-
!buildInfo.gitVersion?.match(/enterprise/)
520+
mongoClientOptions.autoEncryption !== undefined &&
521+
!mongoClientOptions.autoEncryption.bypassAutoEncryption &&
522+
!mongoClientOptions.autoEncryption.bypassQueryAnalysis
450523
) {
451-
throw new MongoAutoencryptionUnavailable();
524+
// connect first without autoEncryption and serverApi options.
525+
const optionsWithoutFLE = { ...mongoClientOptions };
526+
delete optionsWithoutFLE.autoEncryption;
527+
delete optionsWithoutFLE.serverApi;
528+
const client = new MongoClientClass(uri, optionsWithoutFLE);
529+
closeMongoClientWhenAuthFails(state, client);
530+
await connectWithFailFast(uri, client, logger);
531+
const buildInfo = await client
532+
.db('admin')
533+
.admin()
534+
.command({ buildInfo: 1 });
535+
await client.close();
536+
if (
537+
!buildInfo.modules?.includes('enterprise') &&
538+
!buildInfo.gitVersion?.match(/enterprise/)
539+
) {
540+
throw new MongoAutoencryptionUnavailable();
541+
}
542+
}
543+
uri = await resolveMongodbSrv(uri, logger);
544+
const client = new MongoClientClass(uri, mongoClientOptions);
545+
client.once('close', runClose);
546+
closeMongoClientWhenAuthFails(state, client);
547+
await connectWithFailFast(uri, client, logger);
548+
if ((client as any).autoEncrypter) {
549+
// Enable Devtools-specific CSFLE result decoration.
550+
(client as any).autoEncrypter[
551+
Symbol.for('@@mdb.decorateDecryptionResult')
552+
] = true;
452553
}
554+
return { client, state };
555+
} catch (err: unknown) {
556+
await runClose();
557+
throw err;
453558
}
454-
uri = await resolveMongodbSrv(uri, logger);
455-
const client = new MongoClientClass(uri, mongoClientOptions);
456-
closeMongoClientWhenAuthFails(state, client);
457-
await connectWithFailFast(uri, client, logger);
458-
if ((client as any).autoEncrypter) {
459-
// Enable Devtools-specific CSFLE result decoration.
460-
(client as any).autoEncrypter[
461-
Symbol.for('@@mdb.decorateDecryptionResult')
462-
] = true;
559+
}
560+
561+
function hasProxyHostOption(
562+
uri: string,
563+
clientOptions: MongoClientOptions
564+
): boolean {
565+
if (clientOptions.proxyHost || clientOptions.proxyPort) return true;
566+
let cs: ConnectionString;
567+
try {
568+
cs = new ConnectionString(uri, { looseValidation: true });
569+
} catch {
570+
return false;
463571
}
464-
return { client, state };
572+
573+
const sp = cs.typedSearchParams<MongoClientOptions>();
574+
return sp.has('proxyHost') || sp.has('proxyPort');
465575
}
466576

467577
export function isHumanOidcFlow(
@@ -530,16 +640,20 @@ function closeMongoClientWhenAuthFails(
530640
);
531641
}
532642

533-
function addCAToOIDCPluginHttpOptions(
643+
function addToOIDCPluginHttpOptions(
534644
existingOIDCPluginOptions: MongoDBOIDCPluginOptions | undefined,
535-
ca: string
645+
addedOptions: Partial<OIDCHTTPOptions>
536646
): Pick<MongoDBOIDCPluginOptions, 'customHttpOptions'> {
537647
const existingCustomOptions = existingOIDCPluginOptions?.customHttpOptions;
538648
if (typeof existingCustomOptions === 'function') {
539649
return {
540650
customHttpOptions: (url, options, ...restArgs) =>
541-
existingCustomOptions(url, { ...options, ca }, ...restArgs),
651+
existingCustomOptions(
652+
url,
653+
{ ...options, ...addedOptions },
654+
...restArgs
655+
),
542656
};
543657
}
544-
return { customHttpOptions: { ...existingCustomOptions, ca } };
658+
return { customHttpOptions: { ...existingCustomOptions, ...addedOptions } };
545659
}

packages/devtools-connect/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export { connectMongoClient } from './connect';
33
export type {
44
DevtoolsConnectOptions,
55
DevtoolsConnectionState,
6+
DevtoolsProxyOptions,
7+
AgentWithInitialize,
68
} from './connect';
79
export { hookLogger } from './log-hook';
810
export { oidcServerRequestHandler } from './oidc/handler';

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from './types';
1212

1313
import { hookLoggerToMongoLogWriter as oidcHookLogger } from '@mongodb-js/oidc-plugin';
14+
import { hookLogger as proxyHookLogger } from '@mongodb-js/devtools-proxy-support';
1415

1516
interface MongoLogWriter {
1617
info(c: string, id: unknown, ctx: string, msg: string, attr?: any): void;
@@ -26,6 +27,7 @@ export function hookLogger(
2627
redactURICredentials: (uri: string) => string
2728
): void {
2829
oidcHookLogger(emitter, log, contextPrefix);
30+
proxyHookLogger(emitter, log, contextPrefix);
2931

3032
const { mongoLogId } = log;
3133
emitter.on(

0 commit comments

Comments
 (0)