something is working
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
import type { Document } from '../../bson';
|
||||
import { MongoRuntimeError } from '../../error';
|
||||
import type { Callback, ClientMetadataOptions } from '../../utils';
|
||||
import type { HandshakeDocument } from '../connect';
|
||||
import type { Connection, ConnectionOptions } from '../connection';
|
||||
import type { MongoCredentials } from './mongo_credentials';
|
||||
|
||||
export type AuthContextOptions = ConnectionOptions & ClientMetadataOptions;
|
||||
|
||||
/** Context used during authentication */
|
||||
export class AuthContext {
|
||||
/** The connection to authenticate */
|
||||
connection: Connection;
|
||||
/** The credentials to use for authentication */
|
||||
credentials?: MongoCredentials;
|
||||
/** The options passed to the `connect` method */
|
||||
options: AuthContextOptions;
|
||||
|
||||
/** A response from an initial auth attempt, only some mechanisms use this (e.g, SCRAM) */
|
||||
response?: Document;
|
||||
/** A random nonce generated for use in an authentication conversation */
|
||||
nonce?: Buffer;
|
||||
|
||||
constructor(
|
||||
connection: Connection,
|
||||
credentials: MongoCredentials | undefined,
|
||||
options: AuthContextOptions
|
||||
) {
|
||||
this.connection = connection;
|
||||
this.credentials = credentials;
|
||||
this.options = options;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthProvider {
|
||||
/**
|
||||
* Prepare the handshake document before the initial handshake.
|
||||
*
|
||||
* @param handshakeDoc - The document used for the initial handshake on a connection
|
||||
* @param authContext - Context for authentication flow
|
||||
*/
|
||||
prepare(
|
||||
handshakeDoc: HandshakeDocument,
|
||||
authContext: AuthContext,
|
||||
callback: Callback<HandshakeDocument>
|
||||
): void {
|
||||
callback(undefined, handshakeDoc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate
|
||||
*
|
||||
* @param context - A shared context for authentication flow
|
||||
* @param callback - The callback to return the result from the authentication
|
||||
*/
|
||||
auth(context: AuthContext, callback: Callback): void {
|
||||
// TODO(NODE-3483): Replace this with MongoMethodOverrideError
|
||||
callback(new MongoRuntimeError('`auth` method must be overridden by subclass'));
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import * as dns from 'dns';
|
||||
|
||||
import type { Document } from '../../bson';
|
||||
import { Kerberos, KerberosClient } from '../../deps';
|
||||
import {
|
||||
MongoError,
|
||||
MongoInvalidArgumentError,
|
||||
MongoMissingCredentialsError,
|
||||
MongoMissingDependencyError,
|
||||
MongoRuntimeError
|
||||
} from '../../error';
|
||||
import { Callback, ns } from '../../utils';
|
||||
import { AuthContext, AuthProvider } from './auth_provider';
|
||||
|
||||
/** @public */
|
||||
export const GSSAPICanonicalizationValue = Object.freeze({
|
||||
on: true,
|
||||
off: false,
|
||||
none: 'none',
|
||||
forward: 'forward',
|
||||
forwardAndReverse: 'forwardAndReverse'
|
||||
} as const);
|
||||
|
||||
/** @public */
|
||||
export type GSSAPICanonicalizationValue =
|
||||
typeof GSSAPICanonicalizationValue[keyof typeof GSSAPICanonicalizationValue];
|
||||
|
||||
type MechanismProperties = {
|
||||
/** @deprecated use `CANONICALIZE_HOST_NAME` instead */
|
||||
gssapiCanonicalizeHostName?: boolean;
|
||||
CANONICALIZE_HOST_NAME?: GSSAPICanonicalizationValue;
|
||||
SERVICE_HOST?: string;
|
||||
SERVICE_NAME?: string;
|
||||
SERVICE_REALM?: string;
|
||||
};
|
||||
|
||||
export class GSSAPI extends AuthProvider {
|
||||
override auth(authContext: AuthContext, callback: Callback): void {
|
||||
const { connection, credentials } = authContext;
|
||||
if (credentials == null)
|
||||
return callback(
|
||||
new MongoMissingCredentialsError('Credentials required for GSSAPI authentication')
|
||||
);
|
||||
const { username } = credentials;
|
||||
function externalCommand(
|
||||
command: Document,
|
||||
cb: Callback<{ payload: string; conversationId: any }>
|
||||
) {
|
||||
return connection.command(ns('$external.$cmd'), command, undefined, cb);
|
||||
}
|
||||
makeKerberosClient(authContext, (err, client) => {
|
||||
if (err) return callback(err);
|
||||
if (client == null) return callback(new MongoMissingDependencyError('GSSAPI client missing'));
|
||||
client.step('', (err, payload) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
externalCommand(saslStart(payload), (err, result) => {
|
||||
if (err) return callback(err);
|
||||
if (result == null) return callback();
|
||||
negotiate(client, 10, result.payload, (err, payload) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
externalCommand(saslContinue(payload, result.conversationId), (err, result) => {
|
||||
if (err) return callback(err);
|
||||
if (result == null) return callback();
|
||||
finalize(client, username, result.payload, (err, payload) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
externalCommand(
|
||||
{
|
||||
saslContinue: 1,
|
||||
conversationId: result.conversationId,
|
||||
payload
|
||||
},
|
||||
(err, result) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
callback(undefined, result);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function makeKerberosClient(authContext: AuthContext, callback: Callback<KerberosClient>): void {
|
||||
const { hostAddress } = authContext.options;
|
||||
const { credentials } = authContext;
|
||||
if (!hostAddress || typeof hostAddress.host !== 'string' || !credentials) {
|
||||
return callback(
|
||||
new MongoInvalidArgumentError('Connection must have host and port and credentials defined.')
|
||||
);
|
||||
}
|
||||
|
||||
if ('kModuleError' in Kerberos) {
|
||||
return callback(Kerberos['kModuleError']);
|
||||
}
|
||||
const { initializeClient } = Kerberos;
|
||||
|
||||
const { username, password } = credentials;
|
||||
const mechanismProperties = credentials.mechanismProperties as MechanismProperties;
|
||||
|
||||
const serviceName = mechanismProperties.SERVICE_NAME ?? 'mongodb';
|
||||
|
||||
performGSSAPICanonicalizeHostName(
|
||||
hostAddress.host,
|
||||
mechanismProperties,
|
||||
(err?: Error | MongoError, host?: string) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
const initOptions = {};
|
||||
if (password != null) {
|
||||
Object.assign(initOptions, { user: username, password: password });
|
||||
}
|
||||
|
||||
const spnHost = mechanismProperties.SERVICE_HOST ?? host;
|
||||
let spn = `${serviceName}${process.platform === 'win32' ? '/' : '@'}${spnHost}`;
|
||||
if ('SERVICE_REALM' in mechanismProperties) {
|
||||
spn = `${spn}@${mechanismProperties.SERVICE_REALM}`;
|
||||
}
|
||||
|
||||
initializeClient(spn, initOptions, (err: string, client: KerberosClient): void => {
|
||||
// TODO(NODE-3483)
|
||||
if (err) return callback(new MongoRuntimeError(err));
|
||||
callback(undefined, client);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function saslStart(payload?: string): Document {
|
||||
return {
|
||||
saslStart: 1,
|
||||
mechanism: 'GSSAPI',
|
||||
payload,
|
||||
autoAuthorize: 1
|
||||
};
|
||||
}
|
||||
|
||||
function saslContinue(payload?: string, conversationId?: number): Document {
|
||||
return {
|
||||
saslContinue: 1,
|
||||
conversationId,
|
||||
payload
|
||||
};
|
||||
}
|
||||
|
||||
function negotiate(
|
||||
client: KerberosClient,
|
||||
retries: number,
|
||||
payload: string,
|
||||
callback: Callback<string>
|
||||
): void {
|
||||
client.step(payload, (err, response) => {
|
||||
// Retries exhausted, raise error
|
||||
if (err && retries === 0) return callback(err);
|
||||
|
||||
// Adjust number of retries and call step again
|
||||
if (err) return negotiate(client, retries - 1, payload, callback);
|
||||
|
||||
// Return the payload
|
||||
callback(undefined, response || '');
|
||||
});
|
||||
}
|
||||
|
||||
function finalize(
|
||||
client: KerberosClient,
|
||||
user: string,
|
||||
payload: string,
|
||||
callback: Callback<string>
|
||||
): void {
|
||||
// GSS Client Unwrap
|
||||
client.unwrap(payload, (err, response) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
// Wrap the response
|
||||
client.wrap(response || '', { user }, (err, wrapped) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
// Return the payload
|
||||
callback(undefined, wrapped);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function performGSSAPICanonicalizeHostName(
|
||||
host: string,
|
||||
mechanismProperties: MechanismProperties,
|
||||
callback: Callback<string>
|
||||
): void {
|
||||
const mode = mechanismProperties.CANONICALIZE_HOST_NAME;
|
||||
if (!mode || mode === GSSAPICanonicalizationValue.none) {
|
||||
return callback(undefined, host);
|
||||
}
|
||||
|
||||
// If forward and reverse or true
|
||||
if (
|
||||
mode === GSSAPICanonicalizationValue.on ||
|
||||
mode === GSSAPICanonicalizationValue.forwardAndReverse
|
||||
) {
|
||||
// Perform the lookup of the ip address.
|
||||
dns.lookup(host, (error, address) => {
|
||||
// No ip found, return the error.
|
||||
if (error) return callback(error);
|
||||
|
||||
// Perform a reverse ptr lookup on the ip address.
|
||||
dns.resolvePtr(address, (err, results) => {
|
||||
// This can error as ptr records may not exist for all ips. In this case
|
||||
// fallback to a cname lookup as dns.lookup() does not return the
|
||||
// cname.
|
||||
if (err) {
|
||||
return resolveCname(host, callback);
|
||||
}
|
||||
// If the ptr did not error but had no results, return the host.
|
||||
callback(undefined, results.length > 0 ? results[0] : host);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// The case for forward is just to resolve the cname as dns.lookup()
|
||||
// will not return it.
|
||||
resolveCname(host, callback);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCname(host: string, callback: Callback<string>): void {
|
||||
// Attempt to resolve the host name
|
||||
dns.resolveCname(host, (err, r) => {
|
||||
if (err) return callback(undefined, host);
|
||||
|
||||
// Get the first resolve host id
|
||||
if (r.length > 0) {
|
||||
return callback(undefined, r[0]);
|
||||
}
|
||||
|
||||
callback(undefined, host);
|
||||
});
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// Resolves the default auth mechanism according to
|
||||
import type { Document } from '../../bson';
|
||||
import { MongoAPIError, MongoMissingCredentialsError } from '../../error';
|
||||
import { emitWarningOnce } from '../../utils';
|
||||
import { GSSAPICanonicalizationValue } from './gssapi';
|
||||
import { AUTH_MECHS_AUTH_SRC_EXTERNAL, AuthMechanism } from './providers';
|
||||
|
||||
// https://github.com/mongodb/specifications/blob/master/source/auth/auth.rst
|
||||
function getDefaultAuthMechanism(hello?: Document): AuthMechanism {
|
||||
if (hello) {
|
||||
// If hello contains saslSupportedMechs, use scram-sha-256
|
||||
// if it is available, else scram-sha-1
|
||||
if (Array.isArray(hello.saslSupportedMechs)) {
|
||||
return hello.saslSupportedMechs.includes(AuthMechanism.MONGODB_SCRAM_SHA256)
|
||||
? AuthMechanism.MONGODB_SCRAM_SHA256
|
||||
: AuthMechanism.MONGODB_SCRAM_SHA1;
|
||||
}
|
||||
|
||||
// Fallback to legacy selection method. If wire version >= 3, use scram-sha-1
|
||||
if (hello.maxWireVersion >= 3) {
|
||||
return AuthMechanism.MONGODB_SCRAM_SHA1;
|
||||
}
|
||||
}
|
||||
|
||||
// Default for wireprotocol < 3
|
||||
return AuthMechanism.MONGODB_CR;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface AuthMechanismProperties extends Document {
|
||||
SERVICE_HOST?: string;
|
||||
SERVICE_NAME?: string;
|
||||
SERVICE_REALM?: string;
|
||||
CANONICALIZE_HOST_NAME?: GSSAPICanonicalizationValue;
|
||||
AWS_SESSION_TOKEN?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface MongoCredentialsOptions {
|
||||
username: string;
|
||||
password: string;
|
||||
source: string;
|
||||
db?: string;
|
||||
mechanism?: AuthMechanism;
|
||||
mechanismProperties: AuthMechanismProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of the credentials used by MongoDB
|
||||
* @public
|
||||
*/
|
||||
export class MongoCredentials {
|
||||
/** The username used for authentication */
|
||||
readonly username: string;
|
||||
/** The password used for authentication */
|
||||
readonly password: string;
|
||||
/** The database that the user should authenticate against */
|
||||
readonly source: string;
|
||||
/** The method used to authenticate */
|
||||
readonly mechanism: AuthMechanism;
|
||||
/** Special properties used by some types of auth mechanisms */
|
||||
readonly mechanismProperties: AuthMechanismProperties;
|
||||
|
||||
constructor(options: MongoCredentialsOptions) {
|
||||
this.username = options.username;
|
||||
this.password = options.password;
|
||||
this.source = options.source;
|
||||
if (!this.source && options.db) {
|
||||
this.source = options.db;
|
||||
}
|
||||
this.mechanism = options.mechanism || AuthMechanism.MONGODB_DEFAULT;
|
||||
this.mechanismProperties = options.mechanismProperties || {};
|
||||
|
||||
if (this.mechanism.match(/MONGODB-AWS/i)) {
|
||||
if (!this.username && process.env.AWS_ACCESS_KEY_ID) {
|
||||
this.username = process.env.AWS_ACCESS_KEY_ID;
|
||||
}
|
||||
|
||||
if (!this.password && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
this.password = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
}
|
||||
|
||||
if (
|
||||
this.mechanismProperties.AWS_SESSION_TOKEN == null &&
|
||||
process.env.AWS_SESSION_TOKEN != null
|
||||
) {
|
||||
this.mechanismProperties = {
|
||||
...this.mechanismProperties,
|
||||
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ('gssapiCanonicalizeHostName' in this.mechanismProperties) {
|
||||
emitWarningOnce(
|
||||
'gssapiCanonicalizeHostName is deprecated. Please use CANONICALIZE_HOST_NAME instead.'
|
||||
);
|
||||
this.mechanismProperties.CANONICALIZE_HOST_NAME =
|
||||
this.mechanismProperties.gssapiCanonicalizeHostName;
|
||||
}
|
||||
|
||||
Object.freeze(this.mechanismProperties);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
/** Determines if two MongoCredentials objects are equivalent */
|
||||
equals(other: MongoCredentials): boolean {
|
||||
return (
|
||||
this.mechanism === other.mechanism &&
|
||||
this.username === other.username &&
|
||||
this.password === other.password &&
|
||||
this.source === other.source
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the authentication mechanism is set to "default", resolves the authMechanism
|
||||
* based on the server version and server supported sasl mechanisms.
|
||||
*
|
||||
* @param hello - A hello response from the server
|
||||
*/
|
||||
resolveAuthMechanism(hello?: Document): MongoCredentials {
|
||||
// If the mechanism is not "default", then it does not need to be resolved
|
||||
if (this.mechanism.match(/DEFAULT/i)) {
|
||||
return new MongoCredentials({
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
source: this.source,
|
||||
mechanism: getDefaultAuthMechanism(hello),
|
||||
mechanismProperties: this.mechanismProperties
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
validate(): void {
|
||||
if (
|
||||
(this.mechanism === AuthMechanism.MONGODB_GSSAPI ||
|
||||
this.mechanism === AuthMechanism.MONGODB_CR ||
|
||||
this.mechanism === AuthMechanism.MONGODB_PLAIN ||
|
||||
this.mechanism === AuthMechanism.MONGODB_SCRAM_SHA1 ||
|
||||
this.mechanism === AuthMechanism.MONGODB_SCRAM_SHA256) &&
|
||||
!this.username
|
||||
) {
|
||||
throw new MongoMissingCredentialsError(`Username required for mechanism '${this.mechanism}'`);
|
||||
}
|
||||
|
||||
if (AUTH_MECHS_AUTH_SRC_EXTERNAL.has(this.mechanism)) {
|
||||
if (this.source != null && this.source !== '$external') {
|
||||
// TODO(NODE-3485): Replace this with a MongoAuthValidationError
|
||||
throw new MongoAPIError(
|
||||
`Invalid source '${this.source}' for mechanism '${this.mechanism}' specified.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mechanism === AuthMechanism.MONGODB_PLAIN && this.source == null) {
|
||||
// TODO(NODE-3485): Replace this with a MongoAuthValidationError
|
||||
throw new MongoAPIError('PLAIN Authentication Mechanism needs an auth source');
|
||||
}
|
||||
|
||||
if (this.mechanism === AuthMechanism.MONGODB_X509 && this.password != null) {
|
||||
if (this.password === '') {
|
||||
Reflect.set(this, 'password', undefined);
|
||||
return;
|
||||
}
|
||||
// TODO(NODE-3485): Replace this with a MongoAuthValidationError
|
||||
throw new MongoAPIError(`Password not allowed for mechanism MONGODB-X509`);
|
||||
}
|
||||
|
||||
const canonicalization = this.mechanismProperties.CANONICALIZE_HOST_NAME ?? false;
|
||||
if (!Object.values(GSSAPICanonicalizationValue).includes(canonicalization)) {
|
||||
throw new MongoAPIError(`Invalid CANONICALIZE_HOST_NAME value: ${canonicalization}`);
|
||||
}
|
||||
}
|
||||
|
||||
static merge(
|
||||
creds: MongoCredentials | undefined,
|
||||
options: Partial<MongoCredentialsOptions>
|
||||
): MongoCredentials {
|
||||
return new MongoCredentials({
|
||||
username: options.username ?? creds?.username ?? '',
|
||||
password: options.password ?? creds?.password ?? '',
|
||||
mechanism: options.mechanism ?? creds?.mechanism ?? AuthMechanism.MONGODB_DEFAULT,
|
||||
mechanismProperties: options.mechanismProperties ?? creds?.mechanismProperties ?? {},
|
||||
source: options.source ?? options.db ?? creds?.source ?? 'admin'
|
||||
});
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { MongoMissingCredentialsError } from '../../error';
|
||||
import { Callback, ns } from '../../utils';
|
||||
import { AuthContext, AuthProvider } from './auth_provider';
|
||||
|
||||
export class MongoCR extends AuthProvider {
|
||||
override auth(authContext: AuthContext, callback: Callback): void {
|
||||
const { connection, credentials } = authContext;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
const username = credentials.username;
|
||||
const password = credentials.password;
|
||||
const source = credentials.source;
|
||||
connection.command(ns(`${source}.$cmd`), { getnonce: 1 }, undefined, (err, r) => {
|
||||
let nonce = null;
|
||||
let key = null;
|
||||
|
||||
// Get nonce
|
||||
if (err == null) {
|
||||
nonce = r.nonce;
|
||||
|
||||
// Use node md5 generator
|
||||
let md5 = crypto.createHash('md5');
|
||||
|
||||
// Generate keys used for authentication
|
||||
md5.update(`${username}:mongo:${password}`, 'utf8');
|
||||
const hash_password = md5.digest('hex');
|
||||
|
||||
// Final key
|
||||
md5 = crypto.createHash('md5');
|
||||
md5.update(nonce + username + hash_password, 'utf8');
|
||||
key = md5.digest('hex');
|
||||
}
|
||||
|
||||
const authenticateCommand = {
|
||||
authenticate: 1,
|
||||
user: username,
|
||||
nonce,
|
||||
key
|
||||
};
|
||||
|
||||
connection.command(ns(`${source}.$cmd`), authenticateCommand, undefined, callback);
|
||||
});
|
||||
}
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as http from 'http';
|
||||
import * as url from 'url';
|
||||
|
||||
import type { Binary, BSONSerializeOptions } from '../../bson';
|
||||
import * as BSON from '../../bson';
|
||||
import { aws4 } from '../../deps';
|
||||
import {
|
||||
MongoAWSError,
|
||||
MongoCompatibilityError,
|
||||
MongoMissingCredentialsError,
|
||||
MongoRuntimeError
|
||||
} from '../../error';
|
||||
import { Callback, maxWireVersion, ns } from '../../utils';
|
||||
import { AuthContext, AuthProvider } from './auth_provider';
|
||||
import { MongoCredentials } from './mongo_credentials';
|
||||
import { AuthMechanism } from './providers';
|
||||
|
||||
const ASCII_N = 110;
|
||||
const AWS_RELATIVE_URI = 'http://169.254.170.2';
|
||||
const AWS_EC2_URI = 'http://169.254.169.254';
|
||||
const AWS_EC2_PATH = '/latest/meta-data/iam/security-credentials';
|
||||
const bsonOptions: BSONSerializeOptions = {
|
||||
promoteLongs: true,
|
||||
promoteValues: true,
|
||||
promoteBuffers: false,
|
||||
bsonRegExp: false
|
||||
};
|
||||
|
||||
interface AWSSaslContinuePayload {
|
||||
a: string;
|
||||
d: string;
|
||||
t?: string;
|
||||
}
|
||||
|
||||
export class MongoDBAWS extends AuthProvider {
|
||||
override auth(authContext: AuthContext, callback: Callback): void {
|
||||
const { connection, credentials } = authContext;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
|
||||
if ('kModuleError' in aws4) {
|
||||
return callback(aws4['kModuleError']);
|
||||
}
|
||||
const { sign } = aws4;
|
||||
|
||||
if (maxWireVersion(connection) < 9) {
|
||||
callback(
|
||||
new MongoCompatibilityError(
|
||||
'MONGODB-AWS authentication requires MongoDB version 4.4 or later'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!credentials.username) {
|
||||
makeTempCredentials(credentials, (err, tempCredentials) => {
|
||||
if (err || !tempCredentials) return callback(err);
|
||||
|
||||
authContext.credentials = tempCredentials;
|
||||
this.auth(authContext, callback);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const accessKeyId = credentials.username;
|
||||
const secretAccessKey = credentials.password;
|
||||
const sessionToken = credentials.mechanismProperties.AWS_SESSION_TOKEN;
|
||||
|
||||
// If all three defined, include sessionToken, else include username and pass, else no credentials
|
||||
const awsCredentials =
|
||||
accessKeyId && secretAccessKey && sessionToken
|
||||
? { accessKeyId, secretAccessKey, sessionToken }
|
||||
: accessKeyId && secretAccessKey
|
||||
? { accessKeyId, secretAccessKey }
|
||||
: undefined;
|
||||
|
||||
const db = credentials.source;
|
||||
crypto.randomBytes(32, (err, nonce) => {
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const saslStart = {
|
||||
saslStart: 1,
|
||||
mechanism: 'MONGODB-AWS',
|
||||
payload: BSON.serialize({ r: nonce, p: ASCII_N }, bsonOptions)
|
||||
};
|
||||
|
||||
connection.command(ns(`${db}.$cmd`), saslStart, undefined, (err, res) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
const serverResponse = BSON.deserialize(res.payload.buffer, bsonOptions) as {
|
||||
s: Binary;
|
||||
h: string;
|
||||
};
|
||||
const host = serverResponse.h;
|
||||
const serverNonce = serverResponse.s.buffer;
|
||||
if (serverNonce.length !== 64) {
|
||||
callback(
|
||||
// TODO(NODE-3483)
|
||||
new MongoRuntimeError(`Invalid server nonce length ${serverNonce.length}, expected 64`)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverNonce.compare(nonce, 0, nonce.length, 0, nonce.length) !== 0) {
|
||||
// TODO(NODE-3483)
|
||||
callback(new MongoRuntimeError('Server nonce does not begin with client nonce'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (host.length < 1 || host.length > 255 || host.indexOf('..') !== -1) {
|
||||
// TODO(NODE-3483)
|
||||
callback(new MongoRuntimeError(`Server returned an invalid host: "${host}"`));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = 'Action=GetCallerIdentity&Version=2011-06-15';
|
||||
const options = sign(
|
||||
{
|
||||
method: 'POST',
|
||||
host,
|
||||
region: deriveRegion(serverResponse.h),
|
||||
service: 'sts',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': body.length,
|
||||
'X-MongoDB-Server-Nonce': serverNonce.toString('base64'),
|
||||
'X-MongoDB-GS2-CB-Flag': 'n'
|
||||
},
|
||||
path: '/',
|
||||
body
|
||||
},
|
||||
awsCredentials
|
||||
);
|
||||
|
||||
const payload: AWSSaslContinuePayload = {
|
||||
a: options.headers.Authorization,
|
||||
d: options.headers['X-Amz-Date']
|
||||
};
|
||||
if (sessionToken) {
|
||||
payload.t = sessionToken;
|
||||
}
|
||||
|
||||
const saslContinue = {
|
||||
saslContinue: 1,
|
||||
conversationId: 1,
|
||||
payload: BSON.serialize(payload, bsonOptions)
|
||||
};
|
||||
|
||||
connection.command(ns(`${db}.$cmd`), saslContinue, undefined, callback);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface AWSTempCredentials {
|
||||
AccessKeyId?: string;
|
||||
SecretAccessKey?: string;
|
||||
Token?: string;
|
||||
RoleArn?: string;
|
||||
Expiration?: Date;
|
||||
}
|
||||
|
||||
function makeTempCredentials(credentials: MongoCredentials, callback: Callback<MongoCredentials>) {
|
||||
function done(creds: AWSTempCredentials) {
|
||||
if (!creds.AccessKeyId || !creds.SecretAccessKey || !creds.Token) {
|
||||
callback(
|
||||
new MongoMissingCredentialsError('Could not obtain temporary MONGODB-AWS credentials')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(
|
||||
undefined,
|
||||
new MongoCredentials({
|
||||
username: creds.AccessKeyId,
|
||||
password: creds.SecretAccessKey,
|
||||
source: credentials.source,
|
||||
mechanism: AuthMechanism.MONGODB_AWS,
|
||||
mechanismProperties: {
|
||||
AWS_SESSION_TOKEN: creds.Token
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// If the environment variable AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
|
||||
// is set then drivers MUST assume that it was set by an AWS ECS agent
|
||||
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
|
||||
request(
|
||||
`${AWS_RELATIVE_URI}${process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}`,
|
||||
undefined,
|
||||
(err, res) => {
|
||||
if (err) return callback(err);
|
||||
done(res);
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise assume we are on an EC2 instance
|
||||
|
||||
// get a token
|
||||
request(
|
||||
`${AWS_EC2_URI}/latest/api/token`,
|
||||
{ method: 'PUT', json: false, headers: { 'X-aws-ec2-metadata-token-ttl-seconds': 30 } },
|
||||
(err, token) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
// get role name
|
||||
request(
|
||||
`${AWS_EC2_URI}/${AWS_EC2_PATH}`,
|
||||
{ json: false, headers: { 'X-aws-ec2-metadata-token': token } },
|
||||
(err, roleName) => {
|
||||
if (err) return callback(err);
|
||||
|
||||
// get temp credentials
|
||||
request(
|
||||
`${AWS_EC2_URI}/${AWS_EC2_PATH}/${roleName}`,
|
||||
{ headers: { 'X-aws-ec2-metadata-token': token } },
|
||||
(err, creds) => {
|
||||
if (err) return callback(err);
|
||||
done(creds);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function deriveRegion(host: string) {
|
||||
const parts = host.split('.');
|
||||
if (parts.length === 1 || parts[1] === 'amazonaws') {
|
||||
return 'us-east-1';
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
json?: boolean;
|
||||
method?: string;
|
||||
timeout?: number;
|
||||
headers?: http.OutgoingHttpHeaders;
|
||||
}
|
||||
|
||||
function request(uri: string, _options: RequestOptions | undefined, callback: Callback) {
|
||||
const options = Object.assign(
|
||||
{
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
json: true
|
||||
},
|
||||
url.parse(uri),
|
||||
_options
|
||||
);
|
||||
|
||||
const req = http.request(options, res => {
|
||||
res.setEncoding('utf8');
|
||||
|
||||
let data = '';
|
||||
res.on('data', d => (data += d));
|
||||
res.on('end', () => {
|
||||
if (options.json === false) {
|
||||
callback(undefined, data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
callback(undefined, parsed);
|
||||
} catch (err) {
|
||||
// TODO(NODE-3483)
|
||||
callback(new MongoRuntimeError(`Invalid JSON response: "${data}"`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new MongoAWSError(`AWS request to ${uri} timed out after ${options.timeout} ms`));
|
||||
});
|
||||
|
||||
req.on('error', err => callback(err));
|
||||
req.end();
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Binary } from '../../bson';
|
||||
import { MongoMissingCredentialsError } from '../../error';
|
||||
import { Callback, ns } from '../../utils';
|
||||
import { AuthContext, AuthProvider } from './auth_provider';
|
||||
|
||||
export class Plain extends AuthProvider {
|
||||
override auth(authContext: AuthContext, callback: Callback): void {
|
||||
const { connection, credentials } = authContext;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
const username = credentials.username;
|
||||
const password = credentials.password;
|
||||
|
||||
const payload = new Binary(Buffer.from(`\x00${username}\x00${password}`));
|
||||
const command = {
|
||||
saslStart: 1,
|
||||
mechanism: 'PLAIN',
|
||||
payload: payload,
|
||||
autoAuthorize: 1
|
||||
};
|
||||
|
||||
connection.command(ns('$external.$cmd'), command, undefined, callback);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/** @public */
|
||||
export const AuthMechanism = Object.freeze({
|
||||
MONGODB_AWS: 'MONGODB-AWS',
|
||||
MONGODB_CR: 'MONGODB-CR',
|
||||
MONGODB_DEFAULT: 'DEFAULT',
|
||||
MONGODB_GSSAPI: 'GSSAPI',
|
||||
MONGODB_PLAIN: 'PLAIN',
|
||||
MONGODB_SCRAM_SHA1: 'SCRAM-SHA-1',
|
||||
MONGODB_SCRAM_SHA256: 'SCRAM-SHA-256',
|
||||
MONGODB_X509: 'MONGODB-X509'
|
||||
} as const);
|
||||
|
||||
/** @public */
|
||||
export type AuthMechanism = typeof AuthMechanism[keyof typeof AuthMechanism];
|
||||
|
||||
/** @internal */
|
||||
export const AUTH_MECHS_AUTH_SRC_EXTERNAL = new Set<AuthMechanism>([
|
||||
AuthMechanism.MONGODB_GSSAPI,
|
||||
AuthMechanism.MONGODB_AWS,
|
||||
AuthMechanism.MONGODB_X509
|
||||
]);
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { Binary, Document } from '../../bson';
|
||||
import { saslprep } from '../../deps';
|
||||
import {
|
||||
AnyError,
|
||||
MongoInvalidArgumentError,
|
||||
MongoMissingCredentialsError,
|
||||
MongoRuntimeError,
|
||||
MongoServerError
|
||||
} from '../../error';
|
||||
import { Callback, emitWarning, ns } from '../../utils';
|
||||
import type { HandshakeDocument } from '../connect';
|
||||
import { AuthContext, AuthProvider } from './auth_provider';
|
||||
import type { MongoCredentials } from './mongo_credentials';
|
||||
import { AuthMechanism } from './providers';
|
||||
|
||||
type CryptoMethod = 'sha1' | 'sha256';
|
||||
|
||||
class ScramSHA extends AuthProvider {
|
||||
cryptoMethod: CryptoMethod;
|
||||
constructor(cryptoMethod: CryptoMethod) {
|
||||
super();
|
||||
this.cryptoMethod = cryptoMethod || 'sha1';
|
||||
}
|
||||
|
||||
override prepare(handshakeDoc: HandshakeDocument, authContext: AuthContext, callback: Callback) {
|
||||
const cryptoMethod = this.cryptoMethod;
|
||||
const credentials = authContext.credentials;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
if (cryptoMethod === 'sha256' && saslprep == null) {
|
||||
emitWarning('Warning: no saslprep library specified. Passwords will not be sanitized');
|
||||
}
|
||||
|
||||
crypto.randomBytes(24, (err, nonce) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
// store the nonce for later use
|
||||
Object.assign(authContext, { nonce });
|
||||
|
||||
const request = Object.assign({}, handshakeDoc, {
|
||||
speculativeAuthenticate: Object.assign(makeFirstMessage(cryptoMethod, credentials, nonce), {
|
||||
db: credentials.source
|
||||
})
|
||||
});
|
||||
|
||||
callback(undefined, request);
|
||||
});
|
||||
}
|
||||
|
||||
override auth(authContext: AuthContext, callback: Callback) {
|
||||
const response = authContext.response;
|
||||
if (response && response.speculativeAuthenticate) {
|
||||
continueScramConversation(
|
||||
this.cryptoMethod,
|
||||
response.speculativeAuthenticate,
|
||||
authContext,
|
||||
callback
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
executeScram(this.cryptoMethod, authContext, callback);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanUsername(username: string) {
|
||||
return username.replace('=', '=3D').replace(',', '=2C');
|
||||
}
|
||||
|
||||
function clientFirstMessageBare(username: string, nonce: Buffer) {
|
||||
// NOTE: This is done b/c Javascript uses UTF-16, but the server is hashing in UTF-8.
|
||||
// Since the username is not sasl-prep-d, we need to do this here.
|
||||
return Buffer.concat([
|
||||
Buffer.from('n=', 'utf8'),
|
||||
Buffer.from(username, 'utf8'),
|
||||
Buffer.from(',r=', 'utf8'),
|
||||
Buffer.from(nonce.toString('base64'), 'utf8')
|
||||
]);
|
||||
}
|
||||
|
||||
function makeFirstMessage(
|
||||
cryptoMethod: CryptoMethod,
|
||||
credentials: MongoCredentials,
|
||||
nonce: Buffer
|
||||
) {
|
||||
const username = cleanUsername(credentials.username);
|
||||
const mechanism =
|
||||
cryptoMethod === 'sha1' ? AuthMechanism.MONGODB_SCRAM_SHA1 : AuthMechanism.MONGODB_SCRAM_SHA256;
|
||||
|
||||
// NOTE: This is done b/c Javascript uses UTF-16, but the server is hashing in UTF-8.
|
||||
// Since the username is not sasl-prep-d, we need to do this here.
|
||||
return {
|
||||
saslStart: 1,
|
||||
mechanism,
|
||||
payload: new Binary(
|
||||
Buffer.concat([Buffer.from('n,,', 'utf8'), clientFirstMessageBare(username, nonce)])
|
||||
),
|
||||
autoAuthorize: 1,
|
||||
options: { skipEmptyExchange: true }
|
||||
};
|
||||
}
|
||||
|
||||
function executeScram(cryptoMethod: CryptoMethod, authContext: AuthContext, callback: Callback) {
|
||||
const { connection, credentials } = authContext;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
if (!authContext.nonce) {
|
||||
return callback(
|
||||
new MongoInvalidArgumentError('AuthContext must contain a valid nonce property')
|
||||
);
|
||||
}
|
||||
const nonce = authContext.nonce;
|
||||
const db = credentials.source;
|
||||
|
||||
const saslStartCmd = makeFirstMessage(cryptoMethod, credentials, nonce);
|
||||
connection.command(ns(`${db}.$cmd`), saslStartCmd, undefined, (_err, result) => {
|
||||
const err = resolveError(_err, result);
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
continueScramConversation(cryptoMethod, result, authContext, callback);
|
||||
});
|
||||
}
|
||||
|
||||
function continueScramConversation(
|
||||
cryptoMethod: CryptoMethod,
|
||||
response: Document,
|
||||
authContext: AuthContext,
|
||||
callback: Callback
|
||||
) {
|
||||
const connection = authContext.connection;
|
||||
const credentials = authContext.credentials;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
if (!authContext.nonce) {
|
||||
return callback(new MongoInvalidArgumentError('Unable to continue SCRAM without valid nonce'));
|
||||
}
|
||||
const nonce = authContext.nonce;
|
||||
|
||||
const db = credentials.source;
|
||||
const username = cleanUsername(credentials.username);
|
||||
const password = credentials.password;
|
||||
|
||||
let processedPassword;
|
||||
if (cryptoMethod === 'sha256') {
|
||||
processedPassword = 'kModuleError' in saslprep ? password : saslprep(password);
|
||||
} else {
|
||||
try {
|
||||
processedPassword = passwordDigest(username, password);
|
||||
} catch (e) {
|
||||
return callback(e);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = Buffer.isBuffer(response.payload)
|
||||
? new Binary(response.payload)
|
||||
: response.payload;
|
||||
const dict = parsePayload(payload.value());
|
||||
|
||||
const iterations = parseInt(dict.i, 10);
|
||||
if (iterations && iterations < 4096) {
|
||||
callback(
|
||||
// TODO(NODE-3483)
|
||||
new MongoRuntimeError(`Server returned an invalid iteration count ${iterations}`),
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const salt = dict.s;
|
||||
const rnonce = dict.r;
|
||||
if (rnonce.startsWith('nonce')) {
|
||||
// TODO(NODE-3483)
|
||||
callback(new MongoRuntimeError(`Server returned an invalid nonce: ${rnonce}`), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up start of proof
|
||||
const withoutProof = `c=biws,r=${rnonce}`;
|
||||
const saltedPassword = HI(
|
||||
processedPassword,
|
||||
Buffer.from(salt, 'base64'),
|
||||
iterations,
|
||||
cryptoMethod
|
||||
);
|
||||
|
||||
const clientKey = HMAC(cryptoMethod, saltedPassword, 'Client Key');
|
||||
const serverKey = HMAC(cryptoMethod, saltedPassword, 'Server Key');
|
||||
const storedKey = H(cryptoMethod, clientKey);
|
||||
const authMessage = [clientFirstMessageBare(username, nonce), payload.value(), withoutProof].join(
|
||||
','
|
||||
);
|
||||
|
||||
const clientSignature = HMAC(cryptoMethod, storedKey, authMessage);
|
||||
const clientProof = `p=${xor(clientKey, clientSignature)}`;
|
||||
const clientFinal = [withoutProof, clientProof].join(',');
|
||||
|
||||
const serverSignature = HMAC(cryptoMethod, serverKey, authMessage);
|
||||
const saslContinueCmd = {
|
||||
saslContinue: 1,
|
||||
conversationId: response.conversationId,
|
||||
payload: new Binary(Buffer.from(clientFinal))
|
||||
};
|
||||
|
||||
connection.command(ns(`${db}.$cmd`), saslContinueCmd, undefined, (_err, r) => {
|
||||
const err = resolveError(_err, r);
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const parsedResponse = parsePayload(r.payload.value());
|
||||
if (!compareDigest(Buffer.from(parsedResponse.v, 'base64'), serverSignature)) {
|
||||
callback(new MongoRuntimeError('Server returned an invalid signature'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r || r.done !== false) {
|
||||
return callback(err, r);
|
||||
}
|
||||
|
||||
const retrySaslContinueCmd = {
|
||||
saslContinue: 1,
|
||||
conversationId: r.conversationId,
|
||||
payload: Buffer.alloc(0)
|
||||
};
|
||||
|
||||
connection.command(ns(`${db}.$cmd`), retrySaslContinueCmd, undefined, callback);
|
||||
});
|
||||
}
|
||||
|
||||
function parsePayload(payload: string) {
|
||||
const dict: Document = {};
|
||||
const parts = payload.split(',');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const valueParts = parts[i].split('=');
|
||||
dict[valueParts[0]] = valueParts[1];
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
function passwordDigest(username: string, password: string) {
|
||||
if (typeof username !== 'string') {
|
||||
throw new MongoInvalidArgumentError('Username must be a string');
|
||||
}
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
throw new MongoInvalidArgumentError('Password must be a string');
|
||||
}
|
||||
|
||||
if (password.length === 0) {
|
||||
throw new MongoInvalidArgumentError('Password cannot be empty');
|
||||
}
|
||||
|
||||
let md5: crypto.Hash;
|
||||
try {
|
||||
md5 = crypto.createHash('md5');
|
||||
} catch (err) {
|
||||
if (crypto.getFips()) {
|
||||
// This error is (slightly) more helpful than what comes from OpenSSL directly, e.g.
|
||||
// 'Error: error:060800C8:digital envelope routines:EVP_DigestInit_ex:disabled for FIPS'
|
||||
throw new Error('Auth mechanism SCRAM-SHA-1 is not supported in FIPS mode');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
md5.update(`${username}:mongo:${password}`, 'utf8');
|
||||
return md5.digest('hex');
|
||||
}
|
||||
|
||||
// XOR two buffers
|
||||
function xor(a: Buffer, b: Buffer) {
|
||||
if (!Buffer.isBuffer(a)) {
|
||||
a = Buffer.from(a);
|
||||
}
|
||||
|
||||
if (!Buffer.isBuffer(b)) {
|
||||
b = Buffer.from(b);
|
||||
}
|
||||
|
||||
const length = Math.max(a.length, b.length);
|
||||
const res = [];
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
res.push(a[i] ^ b[i]);
|
||||
}
|
||||
|
||||
return Buffer.from(res).toString('base64');
|
||||
}
|
||||
|
||||
function H(method: CryptoMethod, text: Buffer) {
|
||||
return crypto.createHash(method).update(text).digest();
|
||||
}
|
||||
|
||||
function HMAC(method: CryptoMethod, key: Buffer, text: Buffer | string) {
|
||||
return crypto.createHmac(method, key).update(text).digest();
|
||||
}
|
||||
|
||||
interface HICache {
|
||||
[key: string]: Buffer;
|
||||
}
|
||||
|
||||
let _hiCache: HICache = {};
|
||||
let _hiCacheCount = 0;
|
||||
function _hiCachePurge() {
|
||||
_hiCache = {};
|
||||
_hiCacheCount = 0;
|
||||
}
|
||||
|
||||
const hiLengthMap = {
|
||||
sha256: 32,
|
||||
sha1: 20
|
||||
};
|
||||
|
||||
function HI(data: string, salt: Buffer, iterations: number, cryptoMethod: CryptoMethod) {
|
||||
// omit the work if already generated
|
||||
const key = [data, salt.toString('base64'), iterations].join('_');
|
||||
if (_hiCache[key] != null) {
|
||||
return _hiCache[key];
|
||||
}
|
||||
|
||||
// generate the salt
|
||||
const saltedData = crypto.pbkdf2Sync(
|
||||
data,
|
||||
salt,
|
||||
iterations,
|
||||
hiLengthMap[cryptoMethod],
|
||||
cryptoMethod
|
||||
);
|
||||
|
||||
// cache a copy to speed up the next lookup, but prevent unbounded cache growth
|
||||
if (_hiCacheCount >= 200) {
|
||||
_hiCachePurge();
|
||||
}
|
||||
|
||||
_hiCache[key] = saltedData;
|
||||
_hiCacheCount += 1;
|
||||
return saltedData;
|
||||
}
|
||||
|
||||
function compareDigest(lhs: Buffer, rhs: Uint8Array) {
|
||||
if (lhs.length !== rhs.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof crypto.timingSafeEqual === 'function') {
|
||||
return crypto.timingSafeEqual(lhs, rhs);
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < lhs.length; i++) {
|
||||
result |= lhs[i] ^ rhs[i];
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
function resolveError(err?: AnyError, result?: Document) {
|
||||
if (err) return err;
|
||||
if (result) {
|
||||
if (result.$err || result.errmsg) return new MongoServerError(result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export class ScramSHA1 extends ScramSHA {
|
||||
constructor() {
|
||||
super('sha1');
|
||||
}
|
||||
}
|
||||
|
||||
export class ScramSHA256 extends ScramSHA {
|
||||
constructor() {
|
||||
super('sha256');
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import type { Document } from '../../bson';
|
||||
import { MongoMissingCredentialsError } from '../../error';
|
||||
import { Callback, ns } from '../../utils';
|
||||
import type { HandshakeDocument } from '../connect';
|
||||
import { AuthContext, AuthProvider } from './auth_provider';
|
||||
import type { MongoCredentials } from './mongo_credentials';
|
||||
|
||||
export class X509 extends AuthProvider {
|
||||
override prepare(
|
||||
handshakeDoc: HandshakeDocument,
|
||||
authContext: AuthContext,
|
||||
callback: Callback
|
||||
): void {
|
||||
const { credentials } = authContext;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
Object.assign(handshakeDoc, {
|
||||
speculativeAuthenticate: x509AuthenticateCommand(credentials)
|
||||
});
|
||||
|
||||
callback(undefined, handshakeDoc);
|
||||
}
|
||||
|
||||
override auth(authContext: AuthContext, callback: Callback): void {
|
||||
const connection = authContext.connection;
|
||||
const credentials = authContext.credentials;
|
||||
if (!credentials) {
|
||||
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
||||
}
|
||||
const response = authContext.response;
|
||||
|
||||
if (response && response.speculativeAuthenticate) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
connection.command(
|
||||
ns('$external.$cmd'),
|
||||
x509AuthenticateCommand(credentials),
|
||||
undefined,
|
||||
callback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function x509AuthenticateCommand(credentials: MongoCredentials) {
|
||||
const command: Document = { authenticate: 1, mechanism: 'MONGODB-X509' };
|
||||
if (credentials.username) {
|
||||
command.user = credentials.username;
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
import type { Document, ObjectId } from '../bson';
|
||||
import { LEGACY_HELLO_COMMAND, LEGACY_HELLO_COMMAND_CAMEL_CASE } from '../constants';
|
||||
import { calculateDurationInMs, deepCopy } from '../utils';
|
||||
import { Msg, WriteProtocolMessageType } from './commands';
|
||||
import type { Connection } from './connection';
|
||||
|
||||
/**
|
||||
* An event indicating the start of a given
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class CommandStartedEvent {
|
||||
commandObj?: Document;
|
||||
requestId: number;
|
||||
databaseName: string;
|
||||
commandName: string;
|
||||
command: Document;
|
||||
address: string;
|
||||
connectionId?: string | number;
|
||||
serviceId?: ObjectId;
|
||||
|
||||
/**
|
||||
* Create a started event
|
||||
*
|
||||
* @internal
|
||||
* @param pool - the pool that originated the command
|
||||
* @param command - the command
|
||||
*/
|
||||
constructor(connection: Connection, command: WriteProtocolMessageType) {
|
||||
const cmd = extractCommand(command);
|
||||
const commandName = extractCommandName(cmd);
|
||||
const { address, connectionId, serviceId } = extractConnectionDetails(connection);
|
||||
|
||||
// TODO: remove in major revision, this is not spec behavior
|
||||
if (SENSITIVE_COMMANDS.has(commandName)) {
|
||||
this.commandObj = {};
|
||||
this.commandObj[commandName] = true;
|
||||
}
|
||||
|
||||
this.address = address;
|
||||
this.connectionId = connectionId;
|
||||
this.serviceId = serviceId;
|
||||
this.requestId = command.requestId;
|
||||
this.databaseName = databaseName(command);
|
||||
this.commandName = commandName;
|
||||
this.command = maybeRedact(commandName, cmd, cmd);
|
||||
}
|
||||
|
||||
/* @internal */
|
||||
get hasServiceId(): boolean {
|
||||
return !!this.serviceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event indicating the success of a given command
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class CommandSucceededEvent {
|
||||
address: string;
|
||||
connectionId?: string | number;
|
||||
requestId: number;
|
||||
duration: number;
|
||||
commandName: string;
|
||||
reply: unknown;
|
||||
serviceId?: ObjectId;
|
||||
|
||||
/**
|
||||
* Create a succeeded event
|
||||
*
|
||||
* @internal
|
||||
* @param pool - the pool that originated the command
|
||||
* @param command - the command
|
||||
* @param reply - the reply for this command from the server
|
||||
* @param started - a high resolution tuple timestamp of when the command was first sent, to calculate duration
|
||||
*/
|
||||
constructor(
|
||||
connection: Connection,
|
||||
command: WriteProtocolMessageType,
|
||||
reply: Document | undefined,
|
||||
started: number
|
||||
) {
|
||||
const cmd = extractCommand(command);
|
||||
const commandName = extractCommandName(cmd);
|
||||
const { address, connectionId, serviceId } = extractConnectionDetails(connection);
|
||||
|
||||
this.address = address;
|
||||
this.connectionId = connectionId;
|
||||
this.serviceId = serviceId;
|
||||
this.requestId = command.requestId;
|
||||
this.commandName = commandName;
|
||||
this.duration = calculateDurationInMs(started);
|
||||
this.reply = maybeRedact(commandName, cmd, extractReply(command, reply));
|
||||
}
|
||||
|
||||
/* @internal */
|
||||
get hasServiceId(): boolean {
|
||||
return !!this.serviceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event indicating the failure of a given command
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class CommandFailedEvent {
|
||||
address: string;
|
||||
connectionId?: string | number;
|
||||
requestId: number;
|
||||
duration: number;
|
||||
commandName: string;
|
||||
failure: Error;
|
||||
serviceId?: ObjectId;
|
||||
|
||||
/**
|
||||
* Create a failure event
|
||||
*
|
||||
* @internal
|
||||
* @param pool - the pool that originated the command
|
||||
* @param command - the command
|
||||
* @param error - the generated error or a server error response
|
||||
* @param started - a high resolution tuple timestamp of when the command was first sent, to calculate duration
|
||||
*/
|
||||
constructor(
|
||||
connection: Connection,
|
||||
command: WriteProtocolMessageType,
|
||||
error: Error | Document,
|
||||
started: number
|
||||
) {
|
||||
const cmd = extractCommand(command);
|
||||
const commandName = extractCommandName(cmd);
|
||||
const { address, connectionId, serviceId } = extractConnectionDetails(connection);
|
||||
|
||||
this.address = address;
|
||||
this.connectionId = connectionId;
|
||||
this.serviceId = serviceId;
|
||||
|
||||
this.requestId = command.requestId;
|
||||
this.commandName = commandName;
|
||||
this.duration = calculateDurationInMs(started);
|
||||
this.failure = maybeRedact(commandName, cmd, error) as Error;
|
||||
}
|
||||
|
||||
/* @internal */
|
||||
get hasServiceId(): boolean {
|
||||
return !!this.serviceId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Commands that we want to redact because of the sensitive nature of their contents */
|
||||
const SENSITIVE_COMMANDS = new Set([
|
||||
'authenticate',
|
||||
'saslStart',
|
||||
'saslContinue',
|
||||
'getnonce',
|
||||
'createUser',
|
||||
'updateUser',
|
||||
'copydbgetnonce',
|
||||
'copydbsaslstart',
|
||||
'copydb'
|
||||
]);
|
||||
|
||||
const HELLO_COMMANDS = new Set(['hello', LEGACY_HELLO_COMMAND, LEGACY_HELLO_COMMAND_CAMEL_CASE]);
|
||||
|
||||
// helper methods
|
||||
const extractCommandName = (commandDoc: Document) => Object.keys(commandDoc)[0];
|
||||
const namespace = (command: WriteProtocolMessageType) => command.ns;
|
||||
const databaseName = (command: WriteProtocolMessageType) => command.ns.split('.')[0];
|
||||
const collectionName = (command: WriteProtocolMessageType) => command.ns.split('.')[1];
|
||||
const maybeRedact = (commandName: string, commandDoc: Document, result: Error | Document) =>
|
||||
SENSITIVE_COMMANDS.has(commandName) ||
|
||||
(HELLO_COMMANDS.has(commandName) && commandDoc.speculativeAuthenticate)
|
||||
? {}
|
||||
: result;
|
||||
|
||||
const LEGACY_FIND_QUERY_MAP: { [key: string]: string } = {
|
||||
$query: 'filter',
|
||||
$orderby: 'sort',
|
||||
$hint: 'hint',
|
||||
$comment: 'comment',
|
||||
$maxScan: 'maxScan',
|
||||
$max: 'max',
|
||||
$min: 'min',
|
||||
$returnKey: 'returnKey',
|
||||
$showDiskLoc: 'showRecordId',
|
||||
$maxTimeMS: 'maxTimeMS',
|
||||
$snapshot: 'snapshot'
|
||||
};
|
||||
|
||||
const LEGACY_FIND_OPTIONS_MAP = {
|
||||
numberToSkip: 'skip',
|
||||
numberToReturn: 'batchSize',
|
||||
returnFieldSelector: 'projection'
|
||||
} as const;
|
||||
|
||||
const OP_QUERY_KEYS = [
|
||||
'tailable',
|
||||
'oplogReplay',
|
||||
'noCursorTimeout',
|
||||
'awaitData',
|
||||
'partial',
|
||||
'exhaust'
|
||||
] as const;
|
||||
|
||||
/** Extract the actual command from the query, possibly up-converting if it's a legacy format */
|
||||
function extractCommand(command: WriteProtocolMessageType): Document {
|
||||
if (command instanceof Msg) {
|
||||
return deepCopy(command.command);
|
||||
}
|
||||
|
||||
if (command.query?.$query) {
|
||||
let result: Document;
|
||||
if (command.ns === 'admin.$cmd') {
|
||||
// up-convert legacy command
|
||||
result = Object.assign({}, command.query.$query);
|
||||
} else {
|
||||
// up-convert legacy find command
|
||||
result = { find: collectionName(command) };
|
||||
Object.keys(LEGACY_FIND_QUERY_MAP).forEach(key => {
|
||||
if (command.query[key] != null) {
|
||||
result[LEGACY_FIND_QUERY_MAP[key]] = deepCopy(command.query[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(LEGACY_FIND_OPTIONS_MAP).forEach(key => {
|
||||
const legacyKey = key as keyof typeof LEGACY_FIND_OPTIONS_MAP;
|
||||
if (command[legacyKey] != null) {
|
||||
result[LEGACY_FIND_OPTIONS_MAP[legacyKey]] = deepCopy(command[legacyKey]);
|
||||
}
|
||||
});
|
||||
|
||||
OP_QUERY_KEYS.forEach(key => {
|
||||
if (command[key]) {
|
||||
result[key] = command[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (command.pre32Limit != null) {
|
||||
result.limit = command.pre32Limit;
|
||||
}
|
||||
|
||||
if (command.query.$explain) {
|
||||
return { explain: result };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const clonedQuery: Record<string, unknown> = {};
|
||||
const clonedCommand: Record<string, unknown> = {};
|
||||
if (command.query) {
|
||||
for (const k in command.query) {
|
||||
clonedQuery[k] = deepCopy(command.query[k]);
|
||||
}
|
||||
clonedCommand.query = clonedQuery;
|
||||
}
|
||||
|
||||
for (const k in command) {
|
||||
if (k === 'query') continue;
|
||||
clonedCommand[k] = deepCopy((command as unknown as Record<string, unknown>)[k]);
|
||||
}
|
||||
return command.query ? clonedQuery : clonedCommand;
|
||||
}
|
||||
|
||||
function extractReply(command: WriteProtocolMessageType, reply?: Document) {
|
||||
if (!reply) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
if (command instanceof Msg) {
|
||||
return deepCopy(reply.result ? reply.result : reply);
|
||||
}
|
||||
|
||||
// is this a legacy find command?
|
||||
if (command.query && command.query.$query != null) {
|
||||
return {
|
||||
ok: 1,
|
||||
cursor: {
|
||||
id: deepCopy(reply.cursorId),
|
||||
ns: namespace(command),
|
||||
firstBatch: deepCopy(reply.documents)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return deepCopy(reply.result ? reply.result : reply);
|
||||
}
|
||||
|
||||
function extractConnectionDetails(connection: Connection) {
|
||||
let connectionId;
|
||||
if ('id' in connection) {
|
||||
connectionId = connection.id;
|
||||
}
|
||||
return {
|
||||
address: connection.address,
|
||||
serviceId: connection.serviceId,
|
||||
connectionId
|
||||
};
|
||||
}
|
||||
+703
@@ -0,0 +1,703 @@
|
||||
import type { BSONSerializeOptions, Document, Long } from '../bson';
|
||||
import * as BSON from '../bson';
|
||||
import { MongoInvalidArgumentError, MongoRuntimeError } from '../error';
|
||||
import { ReadPreference } from '../read_preference';
|
||||
import type { ClientSession } from '../sessions';
|
||||
import { databaseNamespace } from '../utils';
|
||||
import type { CommandOptions } from './connection';
|
||||
import { OP_MSG, OP_QUERY } from './wire_protocol/constants';
|
||||
|
||||
// Incrementing request id
|
||||
let _requestId = 0;
|
||||
|
||||
// Query flags
|
||||
const OPTS_TAILABLE_CURSOR = 2;
|
||||
const OPTS_SECONDARY = 4;
|
||||
const OPTS_OPLOG_REPLAY = 8;
|
||||
const OPTS_NO_CURSOR_TIMEOUT = 16;
|
||||
const OPTS_AWAIT_DATA = 32;
|
||||
const OPTS_EXHAUST = 64;
|
||||
const OPTS_PARTIAL = 128;
|
||||
|
||||
// Response flags
|
||||
const CURSOR_NOT_FOUND = 1;
|
||||
const QUERY_FAILURE = 2;
|
||||
const SHARD_CONFIG_STALE = 4;
|
||||
const AWAIT_CAPABLE = 8;
|
||||
|
||||
/** @internal */
|
||||
export type WriteProtocolMessageType = Query | Msg;
|
||||
|
||||
/** @internal */
|
||||
export interface OpQueryOptions extends CommandOptions {
|
||||
socketTimeoutMS?: number;
|
||||
session?: ClientSession;
|
||||
documentsReturnedIn?: string;
|
||||
numberToSkip?: number;
|
||||
numberToReturn?: number;
|
||||
returnFieldSelector?: Document;
|
||||
pre32Limit?: number;
|
||||
serializeFunctions?: boolean;
|
||||
ignoreUndefined?: boolean;
|
||||
maxBsonSize?: number;
|
||||
checkKeys?: boolean;
|
||||
secondaryOk?: boolean;
|
||||
|
||||
requestId?: number;
|
||||
moreToCome?: boolean;
|
||||
exhaustAllowed?: boolean;
|
||||
readPreference?: ReadPreference;
|
||||
}
|
||||
|
||||
/**************************************************************
|
||||
* QUERY
|
||||
**************************************************************/
|
||||
/** @internal */
|
||||
export class Query {
|
||||
ns: string;
|
||||
query: Document;
|
||||
numberToSkip: number;
|
||||
numberToReturn: number;
|
||||
returnFieldSelector?: Document;
|
||||
requestId: number;
|
||||
pre32Limit?: number;
|
||||
serializeFunctions: boolean;
|
||||
ignoreUndefined: boolean;
|
||||
maxBsonSize: number;
|
||||
checkKeys: boolean;
|
||||
batchSize: number;
|
||||
tailable: boolean;
|
||||
secondaryOk: boolean;
|
||||
oplogReplay: boolean;
|
||||
noCursorTimeout: boolean;
|
||||
awaitData: boolean;
|
||||
exhaust: boolean;
|
||||
partial: boolean;
|
||||
documentsReturnedIn?: string;
|
||||
|
||||
constructor(ns: string, query: Document, options: OpQueryOptions) {
|
||||
// Basic options needed to be passed in
|
||||
// TODO(NODE-3483): Replace with MongoCommandError
|
||||
if (ns == null) throw new MongoRuntimeError('Namespace must be specified for query');
|
||||
// TODO(NODE-3483): Replace with MongoCommandError
|
||||
if (query == null) throw new MongoRuntimeError('A query document must be specified for query');
|
||||
|
||||
// Validate that we are not passing 0x00 in the collection name
|
||||
if (ns.indexOf('\x00') !== -1) {
|
||||
// TODO(NODE-3483): Use MongoNamespace static method
|
||||
throw new MongoRuntimeError('Namespace cannot contain a null character');
|
||||
}
|
||||
|
||||
// Basic options
|
||||
this.ns = ns;
|
||||
this.query = query;
|
||||
|
||||
// Additional options
|
||||
this.numberToSkip = options.numberToSkip || 0;
|
||||
this.numberToReturn = options.numberToReturn || 0;
|
||||
this.returnFieldSelector = options.returnFieldSelector || undefined;
|
||||
this.requestId = Query.getRequestId();
|
||||
|
||||
// special case for pre-3.2 find commands, delete ASAP
|
||||
this.pre32Limit = options.pre32Limit;
|
||||
|
||||
// Serialization option
|
||||
this.serializeFunctions =
|
||||
typeof options.serializeFunctions === 'boolean' ? options.serializeFunctions : false;
|
||||
this.ignoreUndefined =
|
||||
typeof options.ignoreUndefined === 'boolean' ? options.ignoreUndefined : false;
|
||||
this.maxBsonSize = options.maxBsonSize || 1024 * 1024 * 16;
|
||||
this.checkKeys = typeof options.checkKeys === 'boolean' ? options.checkKeys : false;
|
||||
this.batchSize = this.numberToReturn;
|
||||
|
||||
// Flags
|
||||
this.tailable = false;
|
||||
this.secondaryOk = typeof options.secondaryOk === 'boolean' ? options.secondaryOk : false;
|
||||
this.oplogReplay = false;
|
||||
this.noCursorTimeout = false;
|
||||
this.awaitData = false;
|
||||
this.exhaust = false;
|
||||
this.partial = false;
|
||||
}
|
||||
|
||||
/** Assign next request Id. */
|
||||
incRequestId(): void {
|
||||
this.requestId = _requestId++;
|
||||
}
|
||||
|
||||
/** Peek next request Id. */
|
||||
nextRequestId(): number {
|
||||
return _requestId + 1;
|
||||
}
|
||||
|
||||
/** Increment then return next request Id. */
|
||||
static getRequestId(): number {
|
||||
return ++_requestId;
|
||||
}
|
||||
|
||||
// Uses a single allocated buffer for the process, avoiding multiple memory allocations
|
||||
toBin(): Buffer[] {
|
||||
const buffers = [];
|
||||
let projection = null;
|
||||
|
||||
// Set up the flags
|
||||
let flags = 0;
|
||||
if (this.tailable) {
|
||||
flags |= OPTS_TAILABLE_CURSOR;
|
||||
}
|
||||
|
||||
if (this.secondaryOk) {
|
||||
flags |= OPTS_SECONDARY;
|
||||
}
|
||||
|
||||
if (this.oplogReplay) {
|
||||
flags |= OPTS_OPLOG_REPLAY;
|
||||
}
|
||||
|
||||
if (this.noCursorTimeout) {
|
||||
flags |= OPTS_NO_CURSOR_TIMEOUT;
|
||||
}
|
||||
|
||||
if (this.awaitData) {
|
||||
flags |= OPTS_AWAIT_DATA;
|
||||
}
|
||||
|
||||
if (this.exhaust) {
|
||||
flags |= OPTS_EXHAUST;
|
||||
}
|
||||
|
||||
if (this.partial) {
|
||||
flags |= OPTS_PARTIAL;
|
||||
}
|
||||
|
||||
// If batchSize is different to this.numberToReturn
|
||||
if (this.batchSize !== this.numberToReturn) this.numberToReturn = this.batchSize;
|
||||
|
||||
// Allocate write protocol header buffer
|
||||
const header = Buffer.alloc(
|
||||
4 * 4 + // Header
|
||||
4 + // Flags
|
||||
Buffer.byteLength(this.ns) +
|
||||
1 + // namespace
|
||||
4 + // numberToSkip
|
||||
4 // numberToReturn
|
||||
);
|
||||
|
||||
// Add header to buffers
|
||||
buffers.push(header);
|
||||
|
||||
// Serialize the query
|
||||
const query = BSON.serialize(this.query, {
|
||||
checkKeys: this.checkKeys,
|
||||
serializeFunctions: this.serializeFunctions,
|
||||
ignoreUndefined: this.ignoreUndefined
|
||||
});
|
||||
|
||||
// Add query document
|
||||
buffers.push(query);
|
||||
|
||||
if (this.returnFieldSelector && Object.keys(this.returnFieldSelector).length > 0) {
|
||||
// Serialize the projection document
|
||||
projection = BSON.serialize(this.returnFieldSelector, {
|
||||
checkKeys: this.checkKeys,
|
||||
serializeFunctions: this.serializeFunctions,
|
||||
ignoreUndefined: this.ignoreUndefined
|
||||
});
|
||||
// Add projection document
|
||||
buffers.push(projection);
|
||||
}
|
||||
|
||||
// Total message size
|
||||
const totalLength = header.length + query.length + (projection ? projection.length : 0);
|
||||
|
||||
// Set up the index
|
||||
let index = 4;
|
||||
|
||||
// Write total document length
|
||||
header[3] = (totalLength >> 24) & 0xff;
|
||||
header[2] = (totalLength >> 16) & 0xff;
|
||||
header[1] = (totalLength >> 8) & 0xff;
|
||||
header[0] = totalLength & 0xff;
|
||||
|
||||
// Write header information requestId
|
||||
header[index + 3] = (this.requestId >> 24) & 0xff;
|
||||
header[index + 2] = (this.requestId >> 16) & 0xff;
|
||||
header[index + 1] = (this.requestId >> 8) & 0xff;
|
||||
header[index] = this.requestId & 0xff;
|
||||
index = index + 4;
|
||||
|
||||
// Write header information responseTo
|
||||
header[index + 3] = (0 >> 24) & 0xff;
|
||||
header[index + 2] = (0 >> 16) & 0xff;
|
||||
header[index + 1] = (0 >> 8) & 0xff;
|
||||
header[index] = 0 & 0xff;
|
||||
index = index + 4;
|
||||
|
||||
// Write header information OP_QUERY
|
||||
header[index + 3] = (OP_QUERY >> 24) & 0xff;
|
||||
header[index + 2] = (OP_QUERY >> 16) & 0xff;
|
||||
header[index + 1] = (OP_QUERY >> 8) & 0xff;
|
||||
header[index] = OP_QUERY & 0xff;
|
||||
index = index + 4;
|
||||
|
||||
// Write header information flags
|
||||
header[index + 3] = (flags >> 24) & 0xff;
|
||||
header[index + 2] = (flags >> 16) & 0xff;
|
||||
header[index + 1] = (flags >> 8) & 0xff;
|
||||
header[index] = flags & 0xff;
|
||||
index = index + 4;
|
||||
|
||||
// Write collection name
|
||||
index = index + header.write(this.ns, index, 'utf8') + 1;
|
||||
header[index - 1] = 0;
|
||||
|
||||
// Write header information flags numberToSkip
|
||||
header[index + 3] = (this.numberToSkip >> 24) & 0xff;
|
||||
header[index + 2] = (this.numberToSkip >> 16) & 0xff;
|
||||
header[index + 1] = (this.numberToSkip >> 8) & 0xff;
|
||||
header[index] = this.numberToSkip & 0xff;
|
||||
index = index + 4;
|
||||
|
||||
// Write header information flags numberToReturn
|
||||
header[index + 3] = (this.numberToReturn >> 24) & 0xff;
|
||||
header[index + 2] = (this.numberToReturn >> 16) & 0xff;
|
||||
header[index + 1] = (this.numberToReturn >> 8) & 0xff;
|
||||
header[index] = this.numberToReturn & 0xff;
|
||||
index = index + 4;
|
||||
|
||||
// Return the buffers
|
||||
return buffers;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface MessageHeader {
|
||||
length: number;
|
||||
requestId: number;
|
||||
responseTo: number;
|
||||
opCode: number;
|
||||
fromCompressed?: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface OpResponseOptions extends BSONSerializeOptions {
|
||||
raw?: boolean;
|
||||
documentsReturnedIn?: string | null;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class Response {
|
||||
parsed: boolean;
|
||||
raw: Buffer;
|
||||
data: Buffer;
|
||||
opts: OpResponseOptions;
|
||||
length: number;
|
||||
requestId: number;
|
||||
responseTo: number;
|
||||
opCode: number;
|
||||
fromCompressed?: boolean;
|
||||
responseFlags?: number;
|
||||
cursorId?: Long;
|
||||
startingFrom?: number;
|
||||
numberReturned?: number;
|
||||
documents: (Document | Buffer)[] = new Array(0);
|
||||
cursorNotFound?: boolean;
|
||||
queryFailure?: boolean;
|
||||
shardConfigStale?: boolean;
|
||||
awaitCapable?: boolean;
|
||||
promoteLongs: boolean;
|
||||
promoteValues: boolean;
|
||||
promoteBuffers: boolean;
|
||||
bsonRegExp?: boolean;
|
||||
index?: number;
|
||||
|
||||
constructor(
|
||||
message: Buffer,
|
||||
msgHeader: MessageHeader,
|
||||
msgBody: Buffer,
|
||||
opts?: OpResponseOptions
|
||||
) {
|
||||
this.parsed = false;
|
||||
this.raw = message;
|
||||
this.data = msgBody;
|
||||
this.opts = opts ?? {
|
||||
promoteLongs: true,
|
||||
promoteValues: true,
|
||||
promoteBuffers: false,
|
||||
bsonRegExp: false
|
||||
};
|
||||
|
||||
// Read the message header
|
||||
this.length = msgHeader.length;
|
||||
this.requestId = msgHeader.requestId;
|
||||
this.responseTo = msgHeader.responseTo;
|
||||
this.opCode = msgHeader.opCode;
|
||||
this.fromCompressed = msgHeader.fromCompressed;
|
||||
|
||||
// Flag values
|
||||
this.promoteLongs = typeof this.opts.promoteLongs === 'boolean' ? this.opts.promoteLongs : true;
|
||||
this.promoteValues =
|
||||
typeof this.opts.promoteValues === 'boolean' ? this.opts.promoteValues : true;
|
||||
this.promoteBuffers =
|
||||
typeof this.opts.promoteBuffers === 'boolean' ? this.opts.promoteBuffers : false;
|
||||
this.bsonRegExp = typeof this.opts.bsonRegExp === 'boolean' ? this.opts.bsonRegExp : false;
|
||||
}
|
||||
|
||||
isParsed(): boolean {
|
||||
return this.parsed;
|
||||
}
|
||||
|
||||
parse(options: OpResponseOptions): void {
|
||||
// Don't parse again if not needed
|
||||
if (this.parsed) return;
|
||||
options = options ?? {};
|
||||
|
||||
// Allow the return of raw documents instead of parsing
|
||||
const raw = options.raw || false;
|
||||
const documentsReturnedIn = options.documentsReturnedIn || null;
|
||||
const promoteLongs = options.promoteLongs ?? this.opts.promoteLongs;
|
||||
const promoteValues = options.promoteValues ?? this.opts.promoteValues;
|
||||
const promoteBuffers = options.promoteBuffers ?? this.opts.promoteBuffers;
|
||||
const bsonRegExp = options.bsonRegExp ?? this.opts.bsonRegExp;
|
||||
let bsonSize;
|
||||
|
||||
// Set up the options
|
||||
const _options: BSONSerializeOptions = {
|
||||
promoteLongs,
|
||||
promoteValues,
|
||||
promoteBuffers,
|
||||
bsonRegExp
|
||||
};
|
||||
|
||||
// Position within OP_REPLY at which documents start
|
||||
// (See https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/#wire-op-reply)
|
||||
this.index = 20;
|
||||
|
||||
// Read the message body
|
||||
this.responseFlags = this.data.readInt32LE(0);
|
||||
this.cursorId = new BSON.Long(this.data.readInt32LE(4), this.data.readInt32LE(8));
|
||||
this.startingFrom = this.data.readInt32LE(12);
|
||||
this.numberReturned = this.data.readInt32LE(16);
|
||||
|
||||
// Preallocate document array
|
||||
this.documents = new Array(this.numberReturned);
|
||||
|
||||
this.cursorNotFound = (this.responseFlags & CURSOR_NOT_FOUND) !== 0;
|
||||
this.queryFailure = (this.responseFlags & QUERY_FAILURE) !== 0;
|
||||
this.shardConfigStale = (this.responseFlags & SHARD_CONFIG_STALE) !== 0;
|
||||
this.awaitCapable = (this.responseFlags & AWAIT_CAPABLE) !== 0;
|
||||
|
||||
// Parse Body
|
||||
for (let i = 0; i < this.numberReturned; i++) {
|
||||
bsonSize =
|
||||
this.data[this.index] |
|
||||
(this.data[this.index + 1] << 8) |
|
||||
(this.data[this.index + 2] << 16) |
|
||||
(this.data[this.index + 3] << 24);
|
||||
|
||||
// If we have raw results specified slice the return document
|
||||
if (raw) {
|
||||
this.documents[i] = this.data.slice(this.index, this.index + bsonSize);
|
||||
} else {
|
||||
this.documents[i] = BSON.deserialize(
|
||||
this.data.slice(this.index, this.index + bsonSize),
|
||||
_options
|
||||
);
|
||||
}
|
||||
|
||||
// Adjust the index
|
||||
this.index = this.index + bsonSize;
|
||||
}
|
||||
|
||||
if (this.documents.length === 1 && documentsReturnedIn != null && raw) {
|
||||
const fieldsAsRaw: Document = {};
|
||||
fieldsAsRaw[documentsReturnedIn] = true;
|
||||
_options.fieldsAsRaw = fieldsAsRaw;
|
||||
|
||||
const doc = BSON.deserialize(this.documents[0] as Buffer, _options);
|
||||
this.documents = [doc];
|
||||
}
|
||||
|
||||
// Set parsed
|
||||
this.parsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Implementation of OP_MSG spec:
|
||||
// https://github.com/mongodb/specifications/blob/master/source/message/OP_MSG.rst
|
||||
//
|
||||
// struct Section {
|
||||
// uint8 payloadType;
|
||||
// union payload {
|
||||
// document document; // payloadType == 0
|
||||
// struct sequence { // payloadType == 1
|
||||
// int32 size;
|
||||
// cstring identifier;
|
||||
// document* documents;
|
||||
// };
|
||||
// };
|
||||
// };
|
||||
|
||||
// struct OP_MSG {
|
||||
// struct MsgHeader {
|
||||
// int32 messageLength;
|
||||
// int32 requestID;
|
||||
// int32 responseTo;
|
||||
// int32 opCode = 2013;
|
||||
// };
|
||||
// uint32 flagBits;
|
||||
// Section+ sections;
|
||||
// [uint32 checksum;]
|
||||
// };
|
||||
|
||||
// Msg Flags
|
||||
const OPTS_CHECKSUM_PRESENT = 1;
|
||||
const OPTS_MORE_TO_COME = 2;
|
||||
const OPTS_EXHAUST_ALLOWED = 1 << 16;
|
||||
|
||||
/** @internal */
|
||||
export interface OpMsgOptions {
|
||||
requestId: number;
|
||||
serializeFunctions: boolean;
|
||||
ignoreUndefined: boolean;
|
||||
checkKeys: boolean;
|
||||
maxBsonSize: number;
|
||||
moreToCome: boolean;
|
||||
exhaustAllowed: boolean;
|
||||
readPreference: ReadPreference;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class Msg {
|
||||
ns: string;
|
||||
command: Document;
|
||||
options: OpQueryOptions;
|
||||
requestId: number;
|
||||
serializeFunctions: boolean;
|
||||
ignoreUndefined: boolean;
|
||||
checkKeys: boolean;
|
||||
maxBsonSize: number;
|
||||
checksumPresent: boolean;
|
||||
moreToCome: boolean;
|
||||
exhaustAllowed: boolean;
|
||||
|
||||
constructor(ns: string, command: Document, options: OpQueryOptions) {
|
||||
// Basic options needed to be passed in
|
||||
if (command == null)
|
||||
throw new MongoInvalidArgumentError('Query document must be specified for query');
|
||||
|
||||
// Basic options
|
||||
this.ns = ns;
|
||||
this.command = command;
|
||||
this.command.$db = databaseNamespace(ns);
|
||||
|
||||
if (options.readPreference && options.readPreference.mode !== ReadPreference.PRIMARY) {
|
||||
this.command.$readPreference = options.readPreference.toJSON();
|
||||
}
|
||||
|
||||
// Ensure empty options
|
||||
this.options = options ?? {};
|
||||
|
||||
// Additional options
|
||||
this.requestId = options.requestId ? options.requestId : Msg.getRequestId();
|
||||
|
||||
// Serialization option
|
||||
this.serializeFunctions =
|
||||
typeof options.serializeFunctions === 'boolean' ? options.serializeFunctions : false;
|
||||
this.ignoreUndefined =
|
||||
typeof options.ignoreUndefined === 'boolean' ? options.ignoreUndefined : false;
|
||||
this.checkKeys = typeof options.checkKeys === 'boolean' ? options.checkKeys : false;
|
||||
this.maxBsonSize = options.maxBsonSize || 1024 * 1024 * 16;
|
||||
|
||||
// flags
|
||||
this.checksumPresent = false;
|
||||
this.moreToCome = options.moreToCome || false;
|
||||
this.exhaustAllowed =
|
||||
typeof options.exhaustAllowed === 'boolean' ? options.exhaustAllowed : false;
|
||||
}
|
||||
|
||||
toBin(): Buffer[] {
|
||||
const buffers: Buffer[] = [];
|
||||
let flags = 0;
|
||||
|
||||
if (this.checksumPresent) {
|
||||
flags |= OPTS_CHECKSUM_PRESENT;
|
||||
}
|
||||
|
||||
if (this.moreToCome) {
|
||||
flags |= OPTS_MORE_TO_COME;
|
||||
}
|
||||
|
||||
if (this.exhaustAllowed) {
|
||||
flags |= OPTS_EXHAUST_ALLOWED;
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(
|
||||
4 * 4 + // Header
|
||||
4 // Flags
|
||||
);
|
||||
|
||||
buffers.push(header);
|
||||
|
||||
let totalLength = header.length;
|
||||
const command = this.command;
|
||||
totalLength += this.makeDocumentSegment(buffers, command);
|
||||
|
||||
header.writeInt32LE(totalLength, 0); // messageLength
|
||||
header.writeInt32LE(this.requestId, 4); // requestID
|
||||
header.writeInt32LE(0, 8); // responseTo
|
||||
header.writeInt32LE(OP_MSG, 12); // opCode
|
||||
header.writeUInt32LE(flags, 16); // flags
|
||||
return buffers;
|
||||
}
|
||||
|
||||
makeDocumentSegment(buffers: Buffer[], document: Document): number {
|
||||
const payloadTypeBuffer = Buffer.alloc(1);
|
||||
payloadTypeBuffer[0] = 0;
|
||||
|
||||
const documentBuffer = this.serializeBson(document);
|
||||
buffers.push(payloadTypeBuffer);
|
||||
buffers.push(documentBuffer);
|
||||
|
||||
return payloadTypeBuffer.length + documentBuffer.length;
|
||||
}
|
||||
|
||||
serializeBson(document: Document): Buffer {
|
||||
return BSON.serialize(document, {
|
||||
checkKeys: this.checkKeys,
|
||||
serializeFunctions: this.serializeFunctions,
|
||||
ignoreUndefined: this.ignoreUndefined
|
||||
});
|
||||
}
|
||||
|
||||
static getRequestId(): number {
|
||||
_requestId = (_requestId + 1) & 0x7fffffff;
|
||||
return _requestId;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class BinMsg {
|
||||
parsed: boolean;
|
||||
raw: Buffer;
|
||||
data: Buffer;
|
||||
opts: OpResponseOptions;
|
||||
length: number;
|
||||
requestId: number;
|
||||
responseTo: number;
|
||||
opCode: number;
|
||||
fromCompressed?: boolean;
|
||||
responseFlags: number;
|
||||
checksumPresent: boolean;
|
||||
moreToCome: boolean;
|
||||
exhaustAllowed: boolean;
|
||||
promoteLongs: boolean;
|
||||
promoteValues: boolean;
|
||||
promoteBuffers: boolean;
|
||||
bsonRegExp: boolean;
|
||||
documents: (Document | Buffer)[];
|
||||
index?: number;
|
||||
|
||||
constructor(
|
||||
message: Buffer,
|
||||
msgHeader: MessageHeader,
|
||||
msgBody: Buffer,
|
||||
opts?: OpResponseOptions
|
||||
) {
|
||||
this.parsed = false;
|
||||
this.raw = message;
|
||||
this.data = msgBody;
|
||||
this.opts = opts ?? {
|
||||
promoteLongs: true,
|
||||
promoteValues: true,
|
||||
promoteBuffers: false,
|
||||
bsonRegExp: false
|
||||
};
|
||||
|
||||
// Read the message header
|
||||
this.length = msgHeader.length;
|
||||
this.requestId = msgHeader.requestId;
|
||||
this.responseTo = msgHeader.responseTo;
|
||||
this.opCode = msgHeader.opCode;
|
||||
this.fromCompressed = msgHeader.fromCompressed;
|
||||
|
||||
// Read response flags
|
||||
this.responseFlags = msgBody.readInt32LE(0);
|
||||
this.checksumPresent = (this.responseFlags & OPTS_CHECKSUM_PRESENT) !== 0;
|
||||
this.moreToCome = (this.responseFlags & OPTS_MORE_TO_COME) !== 0;
|
||||
this.exhaustAllowed = (this.responseFlags & OPTS_EXHAUST_ALLOWED) !== 0;
|
||||
this.promoteLongs = typeof this.opts.promoteLongs === 'boolean' ? this.opts.promoteLongs : true;
|
||||
this.promoteValues =
|
||||
typeof this.opts.promoteValues === 'boolean' ? this.opts.promoteValues : true;
|
||||
this.promoteBuffers =
|
||||
typeof this.opts.promoteBuffers === 'boolean' ? this.opts.promoteBuffers : false;
|
||||
this.bsonRegExp = typeof this.opts.bsonRegExp === 'boolean' ? this.opts.bsonRegExp : false;
|
||||
|
||||
this.documents = [];
|
||||
}
|
||||
|
||||
isParsed(): boolean {
|
||||
return this.parsed;
|
||||
}
|
||||
|
||||
parse(options: OpResponseOptions): void {
|
||||
// Don't parse again if not needed
|
||||
if (this.parsed) return;
|
||||
options = options ?? {};
|
||||
|
||||
this.index = 4;
|
||||
// Allow the return of raw documents instead of parsing
|
||||
const raw = options.raw || false;
|
||||
const documentsReturnedIn = options.documentsReturnedIn || null;
|
||||
const promoteLongs = options.promoteLongs ?? this.opts.promoteLongs;
|
||||
const promoteValues = options.promoteValues ?? this.opts.promoteValues;
|
||||
const promoteBuffers = options.promoteBuffers ?? this.opts.promoteBuffers;
|
||||
const bsonRegExp = options.bsonRegExp ?? this.opts.bsonRegExp;
|
||||
const validation = this.parseBsonSerializationOptions(options);
|
||||
|
||||
// Set up the options
|
||||
const bsonOptions: BSONSerializeOptions = {
|
||||
promoteLongs,
|
||||
promoteValues,
|
||||
promoteBuffers,
|
||||
bsonRegExp,
|
||||
validation
|
||||
// Due to the strictness of the BSON libraries validation option we need this cast
|
||||
} as BSONSerializeOptions & { validation: { utf8: { writeErrors: boolean } } };
|
||||
|
||||
while (this.index < this.data.length) {
|
||||
const payloadType = this.data.readUInt8(this.index++);
|
||||
if (payloadType === 0) {
|
||||
const bsonSize = this.data.readUInt32LE(this.index);
|
||||
const bin = this.data.slice(this.index, this.index + bsonSize);
|
||||
this.documents.push(raw ? bin : BSON.deserialize(bin, bsonOptions));
|
||||
this.index += bsonSize;
|
||||
} else if (payloadType === 1) {
|
||||
// It was decided that no driver makes use of payload type 1
|
||||
|
||||
// TODO(NODE-3483): Replace with MongoDeprecationError
|
||||
throw new MongoRuntimeError('OP_MSG Payload Type 1 detected unsupported protocol');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.documents.length === 1 && documentsReturnedIn != null && raw) {
|
||||
const fieldsAsRaw: Document = {};
|
||||
fieldsAsRaw[documentsReturnedIn] = true;
|
||||
bsonOptions.fieldsAsRaw = fieldsAsRaw;
|
||||
const doc = BSON.deserialize(this.documents[0] as Buffer, bsonOptions);
|
||||
this.documents = [doc];
|
||||
}
|
||||
|
||||
this.parsed = true;
|
||||
}
|
||||
|
||||
parseBsonSerializationOptions({ enableUtf8Validation }: BSONSerializeOptions): {
|
||||
utf8: { writeErrors: false } | false;
|
||||
} {
|
||||
if (enableUtf8Validation === false) {
|
||||
return { utf8: false };
|
||||
}
|
||||
|
||||
return { utf8: { writeErrors: false } };
|
||||
}
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
import type { Socket, SocketConnectOpts } from 'net';
|
||||
import * as net from 'net';
|
||||
import { SocksClient } from 'socks';
|
||||
import type { ConnectionOptions as TLSConnectionOpts, TLSSocket } from 'tls';
|
||||
import * as tls from 'tls';
|
||||
|
||||
import type { Document } from '../bson';
|
||||
import { Int32 } from '../bson';
|
||||
import { LEGACY_HELLO_COMMAND } from '../constants';
|
||||
import {
|
||||
MongoCompatibilityError,
|
||||
MongoError,
|
||||
MongoErrorLabel,
|
||||
MongoInvalidArgumentError,
|
||||
MongoNetworkError,
|
||||
MongoNetworkTimeoutError,
|
||||
MongoRuntimeError,
|
||||
MongoServerError,
|
||||
needsRetryableWriteLabel
|
||||
} from '../error';
|
||||
import { Callback, ClientMetadata, HostAddress, makeClientMetadata, ns } from '../utils';
|
||||
import { AuthContext, AuthProvider } from './auth/auth_provider';
|
||||
import { GSSAPI } from './auth/gssapi';
|
||||
import { MongoCR } from './auth/mongocr';
|
||||
import { MongoDBAWS } from './auth/mongodb_aws';
|
||||
import { Plain } from './auth/plain';
|
||||
import { AuthMechanism } from './auth/providers';
|
||||
import { ScramSHA1, ScramSHA256 } from './auth/scram';
|
||||
import { X509 } from './auth/x509';
|
||||
import { Connection, ConnectionOptions, CryptoConnection } from './connection';
|
||||
import {
|
||||
MAX_SUPPORTED_SERVER_VERSION,
|
||||
MAX_SUPPORTED_WIRE_VERSION,
|
||||
MIN_SUPPORTED_SERVER_VERSION,
|
||||
MIN_SUPPORTED_WIRE_VERSION
|
||||
} from './wire_protocol/constants';
|
||||
|
||||
const AUTH_PROVIDERS = new Map<AuthMechanism | string, AuthProvider>([
|
||||
[AuthMechanism.MONGODB_AWS, new MongoDBAWS()],
|
||||
[AuthMechanism.MONGODB_CR, new MongoCR()],
|
||||
[AuthMechanism.MONGODB_GSSAPI, new GSSAPI()],
|
||||
[AuthMechanism.MONGODB_PLAIN, new Plain()],
|
||||
[AuthMechanism.MONGODB_SCRAM_SHA1, new ScramSHA1()],
|
||||
[AuthMechanism.MONGODB_SCRAM_SHA256, new ScramSHA256()],
|
||||
[AuthMechanism.MONGODB_X509, new X509()]
|
||||
]);
|
||||
|
||||
/** @public */
|
||||
export type Stream = Socket | TLSSocket;
|
||||
|
||||
export function connect(options: ConnectionOptions, callback: Callback<Connection>): void {
|
||||
makeConnection({ ...options, existingSocket: undefined }, (err, socket) => {
|
||||
if (err || !socket) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let ConnectionType = options.connectionType ?? Connection;
|
||||
if (options.autoEncrypter) {
|
||||
ConnectionType = CryptoConnection;
|
||||
}
|
||||
performInitialHandshake(new ConnectionType(socket, options), options, callback);
|
||||
});
|
||||
}
|
||||
|
||||
function checkSupportedServer(hello: Document, options: ConnectionOptions) {
|
||||
const serverVersionHighEnough =
|
||||
hello &&
|
||||
(typeof hello.maxWireVersion === 'number' || hello.maxWireVersion instanceof Int32) &&
|
||||
hello.maxWireVersion >= MIN_SUPPORTED_WIRE_VERSION;
|
||||
const serverVersionLowEnough =
|
||||
hello &&
|
||||
(typeof hello.minWireVersion === 'number' || hello.minWireVersion instanceof Int32) &&
|
||||
hello.minWireVersion <= MAX_SUPPORTED_WIRE_VERSION;
|
||||
|
||||
if (serverVersionHighEnough) {
|
||||
if (serverVersionLowEnough) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = `Server at ${options.hostAddress} reports minimum wire version ${JSON.stringify(
|
||||
hello.minWireVersion
|
||||
)}, but this version of the Node.js Driver requires at most ${MAX_SUPPORTED_WIRE_VERSION} (MongoDB ${MAX_SUPPORTED_SERVER_VERSION})`;
|
||||
return new MongoCompatibilityError(message);
|
||||
}
|
||||
|
||||
const message = `Server at ${options.hostAddress} reports maximum wire version ${
|
||||
JSON.stringify(hello.maxWireVersion) ?? 0
|
||||
}, but this version of the Node.js Driver requires at least ${MIN_SUPPORTED_WIRE_VERSION} (MongoDB ${MIN_SUPPORTED_SERVER_VERSION})`;
|
||||
return new MongoCompatibilityError(message);
|
||||
}
|
||||
|
||||
function performInitialHandshake(
|
||||
conn: Connection,
|
||||
options: ConnectionOptions,
|
||||
_callback: Callback
|
||||
) {
|
||||
const callback: Callback<Document> = function (err, ret) {
|
||||
if (err && conn) {
|
||||
conn.destroy();
|
||||
}
|
||||
_callback(err, ret);
|
||||
};
|
||||
|
||||
const credentials = options.credentials;
|
||||
if (credentials) {
|
||||
if (
|
||||
!(credentials.mechanism === AuthMechanism.MONGODB_DEFAULT) &&
|
||||
!AUTH_PROVIDERS.get(credentials.mechanism)
|
||||
) {
|
||||
callback(
|
||||
new MongoInvalidArgumentError(`AuthMechanism '${credentials.mechanism}' not supported`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const authContext = new AuthContext(conn, credentials, options);
|
||||
prepareHandshakeDocument(authContext, (err, handshakeDoc) => {
|
||||
if (err || !handshakeDoc) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const handshakeOptions: Document = Object.assign({}, options);
|
||||
if (typeof options.connectTimeoutMS === 'number') {
|
||||
// The handshake technically is a monitoring check, so its socket timeout should be connectTimeoutMS
|
||||
handshakeOptions.socketTimeoutMS = options.connectTimeoutMS;
|
||||
}
|
||||
|
||||
const start = new Date().getTime();
|
||||
conn.command(ns('admin.$cmd'), handshakeDoc, handshakeOptions, (err, response) => {
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.ok === 0) {
|
||||
callback(new MongoServerError(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('isWritablePrimary' in response)) {
|
||||
// Provide hello-style response document.
|
||||
response.isWritablePrimary = response[LEGACY_HELLO_COMMAND];
|
||||
}
|
||||
|
||||
if (response.helloOk) {
|
||||
conn.helloOk = true;
|
||||
}
|
||||
|
||||
const supportedServerErr = checkSupportedServer(response, options);
|
||||
if (supportedServerErr) {
|
||||
callback(supportedServerErr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.loadBalanced) {
|
||||
if (!response.serviceId) {
|
||||
return callback(
|
||||
new MongoCompatibilityError(
|
||||
'Driver attempted to initialize in load balancing mode, ' +
|
||||
'but the server does not support this mode.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: This is metadata attached to the connection while porting away from
|
||||
// handshake being done in the `Server` class. Likely, it should be
|
||||
// relocated, or at very least restructured.
|
||||
conn.hello = response;
|
||||
conn.lastHelloMS = new Date().getTime() - start;
|
||||
|
||||
if (!response.arbiterOnly && credentials) {
|
||||
// store the response on auth context
|
||||
authContext.response = response;
|
||||
|
||||
const resolvedCredentials = credentials.resolveAuthMechanism(response);
|
||||
const provider = AUTH_PROVIDERS.get(resolvedCredentials.mechanism);
|
||||
if (!provider) {
|
||||
return callback(
|
||||
new MongoInvalidArgumentError(
|
||||
`No AuthProvider for ${resolvedCredentials.mechanism} defined.`
|
||||
)
|
||||
);
|
||||
}
|
||||
provider.auth(authContext, err => {
|
||||
if (err) {
|
||||
if (err instanceof MongoError) {
|
||||
err.addErrorLabel(MongoErrorLabel.HandshakeError);
|
||||
if (needsRetryableWriteLabel(err, response.maxWireVersion)) {
|
||||
err.addErrorLabel(MongoErrorLabel.RetryableWriteError);
|
||||
}
|
||||
}
|
||||
return callback(err);
|
||||
}
|
||||
callback(undefined, conn);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
callback(undefined, conn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface HandshakeDocument extends Document {
|
||||
/**
|
||||
* @deprecated Use hello instead
|
||||
*/
|
||||
ismaster?: boolean;
|
||||
hello?: boolean;
|
||||
helloOk?: boolean;
|
||||
client: ClientMetadata;
|
||||
compression: string[];
|
||||
saslSupportedMechs?: string;
|
||||
loadBalanced?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* This function is only exposed for testing purposes.
|
||||
*/
|
||||
export function prepareHandshakeDocument(
|
||||
authContext: AuthContext,
|
||||
callback: Callback<HandshakeDocument>
|
||||
) {
|
||||
const options = authContext.options;
|
||||
const compressors = options.compressors ? options.compressors : [];
|
||||
const { serverApi } = authContext.connection;
|
||||
|
||||
const handshakeDoc: HandshakeDocument = {
|
||||
[serverApi?.version ? 'hello' : LEGACY_HELLO_COMMAND]: true,
|
||||
helloOk: true,
|
||||
client: options.metadata || makeClientMetadata(options),
|
||||
compression: compressors
|
||||
};
|
||||
|
||||
if (options.loadBalanced === true) {
|
||||
handshakeDoc.loadBalanced = true;
|
||||
}
|
||||
|
||||
const credentials = authContext.credentials;
|
||||
if (credentials) {
|
||||
if (credentials.mechanism === AuthMechanism.MONGODB_DEFAULT && credentials.username) {
|
||||
handshakeDoc.saslSupportedMechs = `${credentials.source}.${credentials.username}`;
|
||||
|
||||
const provider = AUTH_PROVIDERS.get(AuthMechanism.MONGODB_SCRAM_SHA256);
|
||||
if (!provider) {
|
||||
// This auth mechanism is always present.
|
||||
return callback(
|
||||
new MongoInvalidArgumentError(
|
||||
`No AuthProvider for ${AuthMechanism.MONGODB_SCRAM_SHA256} defined.`
|
||||
)
|
||||
);
|
||||
}
|
||||
return provider.prepare(handshakeDoc, authContext, callback);
|
||||
}
|
||||
const provider = AUTH_PROVIDERS.get(credentials.mechanism);
|
||||
if (!provider) {
|
||||
return callback(
|
||||
new MongoInvalidArgumentError(`No AuthProvider for ${credentials.mechanism} defined.`)
|
||||
);
|
||||
}
|
||||
return provider.prepare(handshakeDoc, authContext, callback);
|
||||
}
|
||||
callback(undefined, handshakeDoc);
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const LEGAL_TLS_SOCKET_OPTIONS = [
|
||||
'ALPNProtocols',
|
||||
'ca',
|
||||
'cert',
|
||||
'checkServerIdentity',
|
||||
'ciphers',
|
||||
'crl',
|
||||
'ecdhCurve',
|
||||
'key',
|
||||
'minDHSize',
|
||||
'passphrase',
|
||||
'pfx',
|
||||
'rejectUnauthorized',
|
||||
'secureContext',
|
||||
'secureProtocol',
|
||||
'servername',
|
||||
'session'
|
||||
] as const;
|
||||
|
||||
/** @public */
|
||||
export const LEGAL_TCP_SOCKET_OPTIONS = [
|
||||
'family',
|
||||
'hints',
|
||||
'localAddress',
|
||||
'localPort',
|
||||
'lookup'
|
||||
] as const;
|
||||
|
||||
function parseConnectOptions(options: ConnectionOptions): SocketConnectOpts {
|
||||
const hostAddress = options.hostAddress;
|
||||
if (!hostAddress) throw new MongoInvalidArgumentError('Option "hostAddress" is required');
|
||||
|
||||
const result: Partial<net.TcpNetConnectOpts & net.IpcNetConnectOpts> = {};
|
||||
for (const name of LEGAL_TCP_SOCKET_OPTIONS) {
|
||||
if (options[name] != null) {
|
||||
(result as Document)[name] = options[name];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof hostAddress.socketPath === 'string') {
|
||||
result.path = hostAddress.socketPath;
|
||||
return result as net.IpcNetConnectOpts;
|
||||
} else if (typeof hostAddress.host === 'string') {
|
||||
result.host = hostAddress.host;
|
||||
result.port = hostAddress.port;
|
||||
return result as net.TcpNetConnectOpts;
|
||||
} else {
|
||||
// This should never happen since we set up HostAddresses
|
||||
// But if we don't throw here the socket could hang until timeout
|
||||
// TODO(NODE-3483)
|
||||
throw new MongoRuntimeError(`Unexpected HostAddress ${JSON.stringify(hostAddress)}`);
|
||||
}
|
||||
}
|
||||
|
||||
type MakeConnectionOptions = ConnectionOptions & { existingSocket?: Stream };
|
||||
|
||||
function parseSslOptions(options: MakeConnectionOptions): TLSConnectionOpts {
|
||||
const result: TLSConnectionOpts = parseConnectOptions(options);
|
||||
// Merge in valid SSL options
|
||||
for (const name of LEGAL_TLS_SOCKET_OPTIONS) {
|
||||
if (options[name] != null) {
|
||||
(result as Document)[name] = options[name];
|
||||
}
|
||||
}
|
||||
|
||||
if (options.existingSocket) {
|
||||
result.socket = options.existingSocket;
|
||||
}
|
||||
|
||||
// Set default sni servername to be the same as host
|
||||
if (result.servername == null && result.host && !net.isIP(result.host)) {
|
||||
result.servername = result.host;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const SOCKET_ERROR_EVENT_LIST = ['error', 'close', 'timeout', 'parseError'] as const;
|
||||
type ErrorHandlerEventName = typeof SOCKET_ERROR_EVENT_LIST[number] | 'cancel';
|
||||
const SOCKET_ERROR_EVENTS = new Set(SOCKET_ERROR_EVENT_LIST);
|
||||
|
||||
function makeConnection(options: MakeConnectionOptions, _callback: Callback<Stream>) {
|
||||
const useTLS = options.tls ?? false;
|
||||
const keepAlive = options.keepAlive ?? true;
|
||||
const socketTimeoutMS = options.socketTimeoutMS ?? Reflect.get(options, 'socketTimeout') ?? 0;
|
||||
const noDelay = options.noDelay ?? true;
|
||||
const connectTimeoutMS = options.connectTimeoutMS ?? 30000;
|
||||
const rejectUnauthorized = options.rejectUnauthorized ?? true;
|
||||
const keepAliveInitialDelay =
|
||||
((options.keepAliveInitialDelay ?? 120000) > socketTimeoutMS
|
||||
? Math.round(socketTimeoutMS / 2)
|
||||
: options.keepAliveInitialDelay) ?? 120000;
|
||||
const existingSocket = options.existingSocket;
|
||||
|
||||
let socket: Stream;
|
||||
const callback: Callback<Stream> = function (err, ret) {
|
||||
if (err && socket) {
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
_callback(err, ret);
|
||||
};
|
||||
|
||||
if (options.proxyHost != null) {
|
||||
// Currently, only Socks5 is supported.
|
||||
return makeSocks5Connection(
|
||||
{
|
||||
...options,
|
||||
connectTimeoutMS // Should always be present for Socks5
|
||||
},
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
if (useTLS) {
|
||||
const tlsSocket = tls.connect(parseSslOptions(options));
|
||||
if (typeof tlsSocket.disableRenegotiation === 'function') {
|
||||
tlsSocket.disableRenegotiation();
|
||||
}
|
||||
socket = tlsSocket;
|
||||
} else if (existingSocket) {
|
||||
// In the TLS case, parseSslOptions() sets options.socket to existingSocket,
|
||||
// so we only need to handle the non-TLS case here (where existingSocket
|
||||
// gives us all we need out of the box).
|
||||
socket = existingSocket;
|
||||
} else {
|
||||
socket = net.createConnection(parseConnectOptions(options));
|
||||
}
|
||||
|
||||
socket.setKeepAlive(keepAlive, keepAliveInitialDelay);
|
||||
socket.setTimeout(connectTimeoutMS);
|
||||
socket.setNoDelay(noDelay);
|
||||
|
||||
const connectEvent = useTLS ? 'secureConnect' : 'connect';
|
||||
let cancellationHandler: (err: Error) => void;
|
||||
function errorHandler(eventName: ErrorHandlerEventName) {
|
||||
return (err: Error) => {
|
||||
SOCKET_ERROR_EVENTS.forEach(event => socket.removeAllListeners(event));
|
||||
if (cancellationHandler && options.cancellationToken) {
|
||||
options.cancellationToken.removeListener('cancel', cancellationHandler);
|
||||
}
|
||||
|
||||
socket.removeListener(connectEvent, connectHandler);
|
||||
callback(connectionFailureError(eventName, err));
|
||||
};
|
||||
}
|
||||
|
||||
function connectHandler() {
|
||||
SOCKET_ERROR_EVENTS.forEach(event => socket.removeAllListeners(event));
|
||||
if (cancellationHandler && options.cancellationToken) {
|
||||
options.cancellationToken.removeListener('cancel', cancellationHandler);
|
||||
}
|
||||
|
||||
if ('authorizationError' in socket) {
|
||||
if (socket.authorizationError && rejectUnauthorized) {
|
||||
return callback(socket.authorizationError);
|
||||
}
|
||||
}
|
||||
|
||||
socket.setTimeout(socketTimeoutMS);
|
||||
callback(undefined, socket);
|
||||
}
|
||||
|
||||
SOCKET_ERROR_EVENTS.forEach(event => socket.once(event, errorHandler(event)));
|
||||
if (options.cancellationToken) {
|
||||
cancellationHandler = errorHandler('cancel');
|
||||
options.cancellationToken.once('cancel', cancellationHandler);
|
||||
}
|
||||
|
||||
if (existingSocket) {
|
||||
process.nextTick(connectHandler);
|
||||
} else {
|
||||
socket.once(connectEvent, connectHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function makeSocks5Connection(options: MakeConnectionOptions, callback: Callback<Stream>) {
|
||||
const hostAddress = HostAddress.fromHostPort(
|
||||
options.proxyHost ?? '', // proxyHost is guaranteed to set here
|
||||
options.proxyPort ?? 1080
|
||||
);
|
||||
|
||||
// First, connect to the proxy server itself:
|
||||
makeConnection(
|
||||
{
|
||||
...options,
|
||||
hostAddress,
|
||||
tls: false,
|
||||
proxyHost: undefined
|
||||
},
|
||||
(err, rawSocket) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const destination = parseConnectOptions(options) as net.TcpNetConnectOpts;
|
||||
if (typeof destination.host !== 'string' || typeof destination.port !== 'number') {
|
||||
return callback(
|
||||
new MongoInvalidArgumentError('Can only make Socks5 connections to TCP hosts')
|
||||
);
|
||||
}
|
||||
|
||||
// Then, establish the Socks5 proxy connection:
|
||||
SocksClient.createConnection({
|
||||
existing_socket: rawSocket,
|
||||
timeout: options.connectTimeoutMS,
|
||||
command: 'connect',
|
||||
destination: {
|
||||
host: destination.host,
|
||||
port: destination.port
|
||||
},
|
||||
proxy: {
|
||||
// host and port are ignored because we pass existing_socket
|
||||
host: 'iLoveJavaScript',
|
||||
port: 0,
|
||||
type: 5,
|
||||
userId: options.proxyUsername || undefined,
|
||||
password: options.proxyPassword || undefined
|
||||
}
|
||||
}).then(
|
||||
({ socket }) => {
|
||||
// Finally, now treat the resulting duplex stream as the
|
||||
// socket over which we send and receive wire protocol messages:
|
||||
makeConnection(
|
||||
{
|
||||
...options,
|
||||
existingSocket: socket,
|
||||
proxyHost: undefined
|
||||
},
|
||||
callback
|
||||
);
|
||||
},
|
||||
error => callback(connectionFailureError('error', error))
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function connectionFailureError(type: ErrorHandlerEventName, err: Error) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return new MongoNetworkError(err);
|
||||
case 'timeout':
|
||||
return new MongoNetworkTimeoutError('connection timed out');
|
||||
case 'close':
|
||||
return new MongoNetworkError('connection closed');
|
||||
case 'cancel':
|
||||
return new MongoNetworkError('connection establishment was cancelled');
|
||||
default:
|
||||
return new MongoNetworkError('unknown network error');
|
||||
}
|
||||
}
|
||||
+752
@@ -0,0 +1,752 @@
|
||||
import { clearTimeout, setTimeout } from 'timers';
|
||||
|
||||
import type { BSONSerializeOptions, Document, ObjectId } from '../bson';
|
||||
import {
|
||||
CLOSE,
|
||||
CLUSTER_TIME_RECEIVED,
|
||||
COMMAND_FAILED,
|
||||
COMMAND_STARTED,
|
||||
COMMAND_SUCCEEDED,
|
||||
MESSAGE,
|
||||
PINNED,
|
||||
UNPINNED
|
||||
} from '../constants';
|
||||
import type { AutoEncrypter } from '../deps';
|
||||
import {
|
||||
MongoCompatibilityError,
|
||||
MongoMissingDependencyError,
|
||||
MongoNetworkError,
|
||||
MongoNetworkTimeoutError,
|
||||
MongoServerError,
|
||||
MongoWriteConcernError
|
||||
} from '../error';
|
||||
import type { ServerApi, SupportedNodeConnectionOptions } from '../mongo_client';
|
||||
import { CancellationToken, TypedEventEmitter } from '../mongo_types';
|
||||
import type { ReadPreference, ReadPreferenceLike } from '../read_preference';
|
||||
import { applySession, ClientSession, updateSessionFromResponse } from '../sessions';
|
||||
import {
|
||||
calculateDurationInMs,
|
||||
Callback,
|
||||
ClientMetadata,
|
||||
HostAddress,
|
||||
maxWireVersion,
|
||||
MongoDBNamespace,
|
||||
now,
|
||||
uuidV4
|
||||
} from '../utils';
|
||||
import type { WriteConcern } from '../write_concern';
|
||||
import type { MongoCredentials } from './auth/mongo_credentials';
|
||||
import {
|
||||
CommandFailedEvent,
|
||||
CommandStartedEvent,
|
||||
CommandSucceededEvent
|
||||
} from './command_monitoring_events';
|
||||
import { BinMsg, Msg, Query, Response, WriteProtocolMessageType } from './commands';
|
||||
import type { Stream } from './connect';
|
||||
import { MessageStream, OperationDescription } from './message_stream';
|
||||
import { StreamDescription, StreamDescriptionOptions } from './stream_description';
|
||||
import { getReadPreference, isSharded } from './wire_protocol/shared';
|
||||
|
||||
/** @internal */
|
||||
const kStream = Symbol('stream');
|
||||
/** @internal */
|
||||
const kQueue = Symbol('queue');
|
||||
/** @internal */
|
||||
const kMessageStream = Symbol('messageStream');
|
||||
/** @internal */
|
||||
const kGeneration = Symbol('generation');
|
||||
/** @internal */
|
||||
const kLastUseTime = Symbol('lastUseTime');
|
||||
/** @internal */
|
||||
const kClusterTime = Symbol('clusterTime');
|
||||
/** @internal */
|
||||
const kDescription = Symbol('description');
|
||||
/** @internal */
|
||||
const kHello = Symbol('hello');
|
||||
/** @internal */
|
||||
const kAutoEncrypter = Symbol('autoEncrypter');
|
||||
/** @internal */
|
||||
const kDelayedTimeoutId = Symbol('delayedTimeoutId');
|
||||
|
||||
/** @internal */
|
||||
export interface QueryOptions extends BSONSerializeOptions {
|
||||
readPreference: ReadPreference;
|
||||
documentsReturnedIn?: string;
|
||||
batchSize?: number;
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
projection?: Document;
|
||||
tailable?: boolean;
|
||||
awaitData?: boolean;
|
||||
noCursorTimeout?: boolean;
|
||||
/** @deprecated use `noCursorTimeout` instead */
|
||||
timeout?: boolean;
|
||||
partial?: boolean;
|
||||
oplogReplay?: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface CommandOptions extends BSONSerializeOptions {
|
||||
command?: boolean;
|
||||
secondaryOk?: boolean;
|
||||
/** Specify read preference if command supports it */
|
||||
readPreference?: ReadPreferenceLike;
|
||||
raw?: boolean;
|
||||
monitoring?: boolean;
|
||||
socketTimeoutMS?: number;
|
||||
/** Session to use for the operation */
|
||||
session?: ClientSession;
|
||||
documentsReturnedIn?: string;
|
||||
noResponse?: boolean;
|
||||
omitReadPreference?: boolean;
|
||||
|
||||
// TODO(NODE-2802): Currently the CommandOptions take a property willRetryWrite which is a hint
|
||||
// from executeOperation that the txnNum should be applied to this command.
|
||||
// Applying a session to a command should happen as part of command construction,
|
||||
// most likely in the CommandOperation#executeCommand method, where we have access to
|
||||
// the details we need to determine if a txnNum should also be applied.
|
||||
willRetryWrite?: boolean;
|
||||
|
||||
writeConcern?: WriteConcern;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface GetMoreOptions extends CommandOptions {
|
||||
batchSize?: number;
|
||||
maxTimeMS?: number;
|
||||
maxAwaitTimeMS?: number;
|
||||
/**
|
||||
* Comment to apply to the operation.
|
||||
*
|
||||
* In server versions pre-4.4, 'comment' must be string. A server
|
||||
* error will be thrown if any other type is provided.
|
||||
*
|
||||
* In server versions 4.4 and above, 'comment' can be any valid BSON type.
|
||||
*/
|
||||
comment?: unknown;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ProxyOptions {
|
||||
proxyHost?: string;
|
||||
proxyPort?: number;
|
||||
proxyUsername?: string;
|
||||
proxyPassword?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ConnectionOptions
|
||||
extends SupportedNodeConnectionOptions,
|
||||
StreamDescriptionOptions,
|
||||
ProxyOptions {
|
||||
// Internal creation info
|
||||
id: number | '<monitor>';
|
||||
generation: number;
|
||||
hostAddress: HostAddress;
|
||||
// Settings
|
||||
autoEncrypter?: AutoEncrypter;
|
||||
serverApi?: ServerApi;
|
||||
monitorCommands: boolean;
|
||||
/** @internal */
|
||||
connectionType?: typeof Connection;
|
||||
credentials?: MongoCredentials;
|
||||
connectTimeoutMS?: number;
|
||||
tls: boolean;
|
||||
keepAlive?: boolean;
|
||||
keepAliveInitialDelay?: number;
|
||||
noDelay?: boolean;
|
||||
socketTimeoutMS?: number;
|
||||
cancellationToken?: CancellationToken;
|
||||
|
||||
metadata: ClientMetadata;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface DestroyOptions {
|
||||
/** Force the destruction. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type ConnectionEvents = {
|
||||
commandStarted(event: CommandStartedEvent): void;
|
||||
commandSucceeded(event: CommandSucceededEvent): void;
|
||||
commandFailed(event: CommandFailedEvent): void;
|
||||
clusterTimeReceived(clusterTime: Document): void;
|
||||
close(): void;
|
||||
message(message: any): void;
|
||||
pinned(pinType: string): void;
|
||||
unpinned(pinType: string): void;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export class Connection extends TypedEventEmitter<ConnectionEvents> {
|
||||
id: number | '<monitor>';
|
||||
address: string;
|
||||
socketTimeoutMS: number;
|
||||
monitorCommands: boolean;
|
||||
closed: boolean;
|
||||
destroyed: boolean;
|
||||
lastHelloMS?: number;
|
||||
serverApi?: ServerApi;
|
||||
helloOk?: boolean;
|
||||
|
||||
/**@internal */
|
||||
[kDelayedTimeoutId]: NodeJS.Timeout | null;
|
||||
/** @internal */
|
||||
[kDescription]: StreamDescription;
|
||||
/** @internal */
|
||||
[kGeneration]: number;
|
||||
/** @internal */
|
||||
[kLastUseTime]: number;
|
||||
/** @internal */
|
||||
[kQueue]: Map<number, OperationDescription>;
|
||||
/** @internal */
|
||||
[kMessageStream]: MessageStream;
|
||||
/** @internal */
|
||||
[kStream]: Stream;
|
||||
/** @internal */
|
||||
[kHello]: Document | null;
|
||||
/** @internal */
|
||||
[kClusterTime]: Document | null;
|
||||
|
||||
/** @event */
|
||||
static readonly COMMAND_STARTED = COMMAND_STARTED;
|
||||
/** @event */
|
||||
static readonly COMMAND_SUCCEEDED = COMMAND_SUCCEEDED;
|
||||
/** @event */
|
||||
static readonly COMMAND_FAILED = COMMAND_FAILED;
|
||||
/** @event */
|
||||
static readonly CLUSTER_TIME_RECEIVED = CLUSTER_TIME_RECEIVED;
|
||||
/** @event */
|
||||
static readonly CLOSE = CLOSE;
|
||||
/** @event */
|
||||
static readonly MESSAGE = MESSAGE;
|
||||
/** @event */
|
||||
static readonly PINNED = PINNED;
|
||||
/** @event */
|
||||
static readonly UNPINNED = UNPINNED;
|
||||
|
||||
constructor(stream: Stream, options: ConnectionOptions) {
|
||||
super();
|
||||
this.id = options.id;
|
||||
this.address = streamIdentifier(stream, options);
|
||||
this.socketTimeoutMS = options.socketTimeoutMS ?? 0;
|
||||
this.monitorCommands = options.monitorCommands;
|
||||
this.serverApi = options.serverApi;
|
||||
this.closed = false;
|
||||
this.destroyed = false;
|
||||
this[kHello] = null;
|
||||
this[kClusterTime] = null;
|
||||
|
||||
this[kDescription] = new StreamDescription(this.address, options);
|
||||
this[kGeneration] = options.generation;
|
||||
this[kLastUseTime] = now();
|
||||
|
||||
// setup parser stream and message handling
|
||||
this[kQueue] = new Map();
|
||||
this[kMessageStream] = new MessageStream({
|
||||
...options,
|
||||
maxBsonMessageSize: this.hello?.maxBsonMessageSize
|
||||
});
|
||||
this[kStream] = stream;
|
||||
|
||||
this[kDelayedTimeoutId] = null;
|
||||
|
||||
this[kMessageStream].on('message', message => this.onMessage(message));
|
||||
this[kMessageStream].on('error', error => this.onError(error));
|
||||
this[kStream].on('close', () => this.onClose());
|
||||
this[kStream].on('timeout', () => this.onTimeout());
|
||||
this[kStream].on('error', () => {
|
||||
/* ignore errors, listen to `close` instead */
|
||||
});
|
||||
|
||||
// hook the message stream up to the passed in stream
|
||||
this[kStream].pipe(this[kMessageStream]);
|
||||
this[kMessageStream].pipe(this[kStream]);
|
||||
}
|
||||
|
||||
get description(): StreamDescription {
|
||||
return this[kDescription];
|
||||
}
|
||||
|
||||
get hello(): Document | null {
|
||||
return this[kHello];
|
||||
}
|
||||
|
||||
// the `connect` method stores the result of the handshake hello on the connection
|
||||
set hello(response: Document | null) {
|
||||
this[kDescription].receiveResponse(response);
|
||||
this[kDescription] = Object.freeze(this[kDescription]);
|
||||
|
||||
// TODO: remove this, and only use the `StreamDescription` in the future
|
||||
this[kHello] = response;
|
||||
}
|
||||
|
||||
// Set the whether the message stream is for a monitoring connection.
|
||||
set isMonitoringConnection(value: boolean) {
|
||||
this[kMessageStream].isMonitoringConnection = value;
|
||||
}
|
||||
|
||||
get isMonitoringConnection(): boolean {
|
||||
return this[kMessageStream].isMonitoringConnection;
|
||||
}
|
||||
|
||||
get serviceId(): ObjectId | undefined {
|
||||
return this.hello?.serviceId;
|
||||
}
|
||||
|
||||
get loadBalanced(): boolean {
|
||||
return this.description.loadBalanced;
|
||||
}
|
||||
|
||||
get generation(): number {
|
||||
return this[kGeneration] || 0;
|
||||
}
|
||||
|
||||
set generation(generation: number) {
|
||||
this[kGeneration] = generation;
|
||||
}
|
||||
|
||||
get idleTime(): number {
|
||||
return calculateDurationInMs(this[kLastUseTime]);
|
||||
}
|
||||
|
||||
get clusterTime(): Document | null {
|
||||
return this[kClusterTime];
|
||||
}
|
||||
|
||||
get stream(): Stream {
|
||||
return this[kStream];
|
||||
}
|
||||
|
||||
markAvailable(): void {
|
||||
this[kLastUseTime] = now();
|
||||
}
|
||||
|
||||
onError(error: Error) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[kStream].destroy(error);
|
||||
|
||||
this.closed = true;
|
||||
|
||||
for (const op of this[kQueue].values()) {
|
||||
op.cb(error);
|
||||
}
|
||||
|
||||
this[kQueue].clear();
|
||||
this.emit(Connection.CLOSE);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
const message = `connection ${this.id} to ${this.address} closed`;
|
||||
for (const op of this[kQueue].values()) {
|
||||
op.cb(new MongoNetworkError(message));
|
||||
}
|
||||
|
||||
this[kQueue].clear();
|
||||
this.emit(Connection.CLOSE);
|
||||
}
|
||||
|
||||
onTimeout() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[kDelayedTimeoutId] = setTimeout(() => {
|
||||
this[kStream].destroy();
|
||||
|
||||
this.closed = true;
|
||||
|
||||
const message = `connection ${this.id} to ${this.address} timed out`;
|
||||
const beforeHandshake = this.hello == null;
|
||||
for (const op of this[kQueue].values()) {
|
||||
op.cb(new MongoNetworkTimeoutError(message, { beforeHandshake }));
|
||||
}
|
||||
|
||||
this[kQueue].clear();
|
||||
this.emit(Connection.CLOSE);
|
||||
}, 1).unref(); // No need for this timer to hold the event loop open
|
||||
}
|
||||
|
||||
onMessage(message: BinMsg | Response) {
|
||||
const delayedTimeoutId = this[kDelayedTimeoutId];
|
||||
if (delayedTimeoutId != null) {
|
||||
clearTimeout(delayedTimeoutId);
|
||||
this[kDelayedTimeoutId] = null;
|
||||
}
|
||||
|
||||
// always emit the message, in case we are streaming
|
||||
this.emit('message', message);
|
||||
const operationDescription = this[kQueue].get(message.responseTo);
|
||||
if (!operationDescription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = operationDescription.cb;
|
||||
|
||||
// SERVER-45775: For exhaust responses we should be able to use the same requestId to
|
||||
// track response, however the server currently synthetically produces remote requests
|
||||
// making the `responseTo` change on each response
|
||||
this[kQueue].delete(message.responseTo);
|
||||
if ('moreToCome' in message && message.moreToCome) {
|
||||
// requeue the callback for next synthetic request
|
||||
this[kQueue].set(message.requestId, operationDescription);
|
||||
} else if (operationDescription.socketTimeoutOverride) {
|
||||
this[kStream].setTimeout(this.socketTimeoutMS);
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass in the entire description because it has BSON parsing options
|
||||
message.parse(operationDescription);
|
||||
} catch (err) {
|
||||
// If this error is generated by our own code, it will already have the correct class applied
|
||||
// if it is not, then it is coming from a catastrophic data parse failure or the BSON library
|
||||
// in either case, it should not be wrapped
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.documents[0]) {
|
||||
const document: Document = message.documents[0];
|
||||
const session = operationDescription.session;
|
||||
if (session) {
|
||||
updateSessionFromResponse(session, document);
|
||||
}
|
||||
|
||||
if (document.$clusterTime) {
|
||||
this[kClusterTime] = document.$clusterTime;
|
||||
this.emit(Connection.CLUSTER_TIME_RECEIVED, document.$clusterTime);
|
||||
}
|
||||
|
||||
if (operationDescription.command) {
|
||||
if (document.writeConcernError) {
|
||||
callback(new MongoWriteConcernError(document.writeConcernError, document));
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.ok === 0 || document.$err || document.errmsg || document.code) {
|
||||
callback(new MongoServerError(document));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Pre 3.2 support
|
||||
if (document.ok === 0 || document.$err || document.errmsg) {
|
||||
callback(new MongoServerError(document));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback(undefined, message.documents[0]);
|
||||
}
|
||||
|
||||
destroy(options?: DestroyOptions, callback?: Callback): void {
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = { force: false };
|
||||
}
|
||||
|
||||
this.removeAllListeners(Connection.PINNED);
|
||||
this.removeAllListeners(Connection.UNPINNED);
|
||||
|
||||
options = Object.assign({ force: false }, options);
|
||||
if (this[kStream] == null || this.destroyed) {
|
||||
this.destroyed = true;
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.force) {
|
||||
this[kStream].destroy();
|
||||
this.destroyed = true;
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this[kStream].end(() => {
|
||||
this.destroyed = true;
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
command(
|
||||
ns: MongoDBNamespace,
|
||||
cmd: Document,
|
||||
options: CommandOptions | undefined,
|
||||
callback: Callback
|
||||
): void {
|
||||
const readPreference = getReadPreference(cmd, options);
|
||||
const shouldUseOpMsg = supportsOpMsg(this);
|
||||
const session = options?.session;
|
||||
|
||||
let clusterTime = this.clusterTime;
|
||||
let finalCmd = Object.assign({}, cmd);
|
||||
|
||||
if (this.serverApi) {
|
||||
const { version, strict, deprecationErrors } = this.serverApi;
|
||||
finalCmd.apiVersion = version;
|
||||
if (strict != null) finalCmd.apiStrict = strict;
|
||||
if (deprecationErrors != null) finalCmd.apiDeprecationErrors = deprecationErrors;
|
||||
}
|
||||
|
||||
if (hasSessionSupport(this) && session) {
|
||||
if (
|
||||
session.clusterTime &&
|
||||
clusterTime &&
|
||||
session.clusterTime.clusterTime.greaterThan(clusterTime.clusterTime)
|
||||
) {
|
||||
clusterTime = session.clusterTime;
|
||||
}
|
||||
|
||||
const err = applySession(session, finalCmd, options);
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
// if we have a known cluster time, gossip it
|
||||
if (clusterTime) {
|
||||
finalCmd.$clusterTime = clusterTime;
|
||||
}
|
||||
|
||||
if (isSharded(this) && !shouldUseOpMsg && readPreference && readPreference.mode !== 'primary') {
|
||||
finalCmd = {
|
||||
$query: finalCmd,
|
||||
$readPreference: readPreference.toJSON()
|
||||
};
|
||||
}
|
||||
|
||||
const commandOptions: Document = Object.assign(
|
||||
{
|
||||
command: true,
|
||||
numberToSkip: 0,
|
||||
numberToReturn: -1,
|
||||
checkKeys: false,
|
||||
// This value is not overridable
|
||||
secondaryOk: readPreference.secondaryOk()
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
const cmdNs = `${ns.db}.$cmd`;
|
||||
const message = shouldUseOpMsg
|
||||
? new Msg(cmdNs, finalCmd, commandOptions)
|
||||
: new Query(cmdNs, finalCmd, commandOptions);
|
||||
|
||||
try {
|
||||
write(this, message, commandOptions, callback);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class CryptoConnection extends Connection {
|
||||
/** @internal */
|
||||
[kAutoEncrypter]?: AutoEncrypter;
|
||||
|
||||
constructor(stream: Stream, options: ConnectionOptions) {
|
||||
super(stream, options);
|
||||
this[kAutoEncrypter] = options.autoEncrypter;
|
||||
}
|
||||
|
||||
/** @internal @override */
|
||||
override command(
|
||||
ns: MongoDBNamespace,
|
||||
cmd: Document,
|
||||
options: CommandOptions,
|
||||
callback: Callback
|
||||
): void {
|
||||
const autoEncrypter = this[kAutoEncrypter];
|
||||
if (!autoEncrypter) {
|
||||
return callback(new MongoMissingDependencyError('No AutoEncrypter available for encryption'));
|
||||
}
|
||||
|
||||
const serverWireVersion = maxWireVersion(this);
|
||||
if (serverWireVersion === 0) {
|
||||
// This means the initial handshake hasn't happened yet
|
||||
return super.command(ns, cmd, options, callback);
|
||||
}
|
||||
|
||||
if (serverWireVersion < 8) {
|
||||
callback(
|
||||
new MongoCompatibilityError('Auto-encryption requires a minimum MongoDB version of 4.2')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save sort or indexKeys based on the command being run
|
||||
// the encrypt API serializes our JS objects to BSON to pass to the native code layer
|
||||
// and then deserializes the encrypted result, the protocol level components
|
||||
// of the command (ex. sort) are then converted to JS objects potentially losing
|
||||
// import key order information. These fields are never encrypted so we can save the values
|
||||
// from before the encryption and replace them after encryption has been performed
|
||||
const sort: Map<string, number> | null = cmd.find || cmd.findAndModify ? cmd.sort : null;
|
||||
const indexKeys: Map<string, number>[] | null = cmd.createIndexes
|
||||
? cmd.indexes.map((index: { key: Map<string, number> }) => index.key)
|
||||
: null;
|
||||
|
||||
autoEncrypter.encrypt(ns.toString(), cmd, options, (err, encrypted) => {
|
||||
if (err || encrypted == null) {
|
||||
callback(err, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the saved values
|
||||
if (sort != null && (cmd.find || cmd.findAndModify)) {
|
||||
encrypted.sort = sort;
|
||||
}
|
||||
if (indexKeys != null && cmd.createIndexes) {
|
||||
for (const [offset, index] of indexKeys.entries()) {
|
||||
encrypted.indexes[offset].key = index;
|
||||
}
|
||||
}
|
||||
|
||||
super.command(ns, encrypted, options, (err, response) => {
|
||||
if (err || response == null) {
|
||||
callback(err, response);
|
||||
return;
|
||||
}
|
||||
|
||||
autoEncrypter.decrypt(response, options, callback);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function hasSessionSupport(conn: Connection): boolean {
|
||||
const description = conn.description;
|
||||
return description.logicalSessionTimeoutMinutes != null || !!description.loadBalanced;
|
||||
}
|
||||
|
||||
function supportsOpMsg(conn: Connection) {
|
||||
const description = conn.description;
|
||||
if (description == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return maxWireVersion(conn) >= 6 && !description.__nodejs_mock_server__;
|
||||
}
|
||||
|
||||
function streamIdentifier(stream: Stream, options: ConnectionOptions): string {
|
||||
if (options.proxyHost) {
|
||||
// If proxy options are specified, the properties of `stream` itself
|
||||
// will not accurately reflect what endpoint this is connected to.
|
||||
return options.hostAddress.toString();
|
||||
}
|
||||
|
||||
if (typeof stream.address === 'function') {
|
||||
return `${stream.remoteAddress}:${stream.remotePort}`;
|
||||
}
|
||||
|
||||
return uuidV4().toString('hex');
|
||||
}
|
||||
|
||||
function write(
|
||||
conn: Connection,
|
||||
command: WriteProtocolMessageType,
|
||||
options: CommandOptions,
|
||||
callback: Callback
|
||||
) {
|
||||
options = options ?? {};
|
||||
const operationDescription: OperationDescription = {
|
||||
requestId: command.requestId,
|
||||
cb: callback,
|
||||
session: options.session,
|
||||
noResponse: typeof options.noResponse === 'boolean' ? options.noResponse : false,
|
||||
documentsReturnedIn: options.documentsReturnedIn,
|
||||
command: !!options.command,
|
||||
|
||||
// for BSON parsing
|
||||
promoteLongs: typeof options.promoteLongs === 'boolean' ? options.promoteLongs : true,
|
||||
promoteValues: typeof options.promoteValues === 'boolean' ? options.promoteValues : true,
|
||||
promoteBuffers: typeof options.promoteBuffers === 'boolean' ? options.promoteBuffers : false,
|
||||
bsonRegExp: typeof options.bsonRegExp === 'boolean' ? options.bsonRegExp : false,
|
||||
enableUtf8Validation:
|
||||
typeof options.enableUtf8Validation === 'boolean' ? options.enableUtf8Validation : true,
|
||||
raw: typeof options.raw === 'boolean' ? options.raw : false,
|
||||
started: 0
|
||||
};
|
||||
|
||||
if (conn[kDescription] && conn[kDescription].compressor) {
|
||||
operationDescription.agreedCompressor = conn[kDescription].compressor;
|
||||
|
||||
if (conn[kDescription].zlibCompressionLevel) {
|
||||
operationDescription.zlibCompressionLevel = conn[kDescription].zlibCompressionLevel;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.socketTimeoutMS === 'number') {
|
||||
operationDescription.socketTimeoutOverride = true;
|
||||
conn[kStream].setTimeout(options.socketTimeoutMS);
|
||||
}
|
||||
|
||||
// if command monitoring is enabled we need to modify the callback here
|
||||
if (conn.monitorCommands) {
|
||||
conn.emit(Connection.COMMAND_STARTED, new CommandStartedEvent(conn, command));
|
||||
|
||||
operationDescription.started = now();
|
||||
operationDescription.cb = (err, reply) => {
|
||||
if (err) {
|
||||
conn.emit(
|
||||
Connection.COMMAND_FAILED,
|
||||
new CommandFailedEvent(conn, command, err, operationDescription.started)
|
||||
);
|
||||
} else {
|
||||
if (reply && (reply.ok === 0 || reply.$err)) {
|
||||
conn.emit(
|
||||
Connection.COMMAND_FAILED,
|
||||
new CommandFailedEvent(conn, command, reply, operationDescription.started)
|
||||
);
|
||||
} else {
|
||||
conn.emit(
|
||||
Connection.COMMAND_SUCCEEDED,
|
||||
new CommandSucceededEvent(conn, command, reply, operationDescription.started)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof callback === 'function') {
|
||||
callback(err, reply);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!operationDescription.noResponse) {
|
||||
conn[kQueue].set(operationDescription.requestId, operationDescription);
|
||||
}
|
||||
|
||||
try {
|
||||
conn[kMessageStream].writeCommand(command, operationDescription);
|
||||
} catch (e) {
|
||||
if (!operationDescription.noResponse) {
|
||||
conn[kQueue].delete(operationDescription.requestId);
|
||||
operationDescription.cb(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (operationDescription.noResponse) {
|
||||
operationDescription.cb();
|
||||
}
|
||||
}
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
import Denque = require('denque');
|
||||
import { clearTimeout, setTimeout } from 'timers';
|
||||
|
||||
import type { ObjectId } from '../bson';
|
||||
import {
|
||||
APM_EVENTS,
|
||||
CONNECTION_CHECK_OUT_FAILED,
|
||||
CONNECTION_CHECK_OUT_STARTED,
|
||||
CONNECTION_CHECKED_IN,
|
||||
CONNECTION_CHECKED_OUT,
|
||||
CONNECTION_CLOSED,
|
||||
CONNECTION_CREATED,
|
||||
CONNECTION_POOL_CLEARED,
|
||||
CONNECTION_POOL_CLOSED,
|
||||
CONNECTION_POOL_CREATED,
|
||||
CONNECTION_READY
|
||||
} from '../constants';
|
||||
import { MongoError, MongoInvalidArgumentError, MongoRuntimeError } from '../error';
|
||||
import { Logger } from '../logger';
|
||||
import { CancellationToken, TypedEventEmitter } from '../mongo_types';
|
||||
import { Callback, eachAsync, makeCounter } from '../utils';
|
||||
import { connect } from './connect';
|
||||
import { Connection, ConnectionEvents, ConnectionOptions } from './connection';
|
||||
import {
|
||||
ConnectionCheckedInEvent,
|
||||
ConnectionCheckedOutEvent,
|
||||
ConnectionCheckOutFailedEvent,
|
||||
ConnectionCheckOutStartedEvent,
|
||||
ConnectionClosedEvent,
|
||||
ConnectionCreatedEvent,
|
||||
ConnectionPoolClearedEvent,
|
||||
ConnectionPoolClosedEvent,
|
||||
ConnectionPoolCreatedEvent,
|
||||
ConnectionReadyEvent
|
||||
} from './connection_pool_events';
|
||||
import { PoolClosedError, WaitQueueTimeoutError } from './errors';
|
||||
import { ConnectionPoolMetrics } from './metrics';
|
||||
|
||||
/** @internal */
|
||||
const kLogger = Symbol('logger');
|
||||
/** @internal */
|
||||
const kConnections = Symbol('connections');
|
||||
/** @internal */
|
||||
const kPending = Symbol('pending');
|
||||
/** @internal */
|
||||
const kCheckedOut = Symbol('checkedOut');
|
||||
/** @internal */
|
||||
const kMinPoolSizeTimer = Symbol('minPoolSizeTimer');
|
||||
/** @internal */
|
||||
const kGeneration = Symbol('generation');
|
||||
/** @internal */
|
||||
const kServiceGenerations = Symbol('serviceGenerations');
|
||||
/** @internal */
|
||||
const kConnectionCounter = Symbol('connectionCounter');
|
||||
/** @internal */
|
||||
const kCancellationToken = Symbol('cancellationToken');
|
||||
/** @internal */
|
||||
const kWaitQueue = Symbol('waitQueue');
|
||||
/** @internal */
|
||||
const kCancelled = Symbol('cancelled');
|
||||
/** @internal */
|
||||
const kMetrics = Symbol('metrics');
|
||||
/** @internal */
|
||||
const kProcessingWaitQueue = Symbol('processingWaitQueue');
|
||||
/** @internal */
|
||||
const kPoolState = Symbol('poolState');
|
||||
|
||||
/** @public */
|
||||
export interface ConnectionPoolOptions extends Omit<ConnectionOptions, 'id' | 'generation'> {
|
||||
/** The maximum number of connections that may be associated with a pool at a given time. This includes in use and available connections. */
|
||||
maxPoolSize: number;
|
||||
/** The minimum number of connections that MUST exist at any moment in a single connection pool. */
|
||||
minPoolSize: number;
|
||||
/** The maximum number of connections that may be in the process of being established concurrently by the connection pool. */
|
||||
maxConnecting: number;
|
||||
/** The maximum amount of time a connection should remain idle in the connection pool before being marked idle. */
|
||||
maxIdleTimeMS: number;
|
||||
/** The maximum amount of time operation execution should wait for a connection to become available. The default is 0 which means there is no limit. */
|
||||
waitQueueTimeoutMS: number;
|
||||
/** If we are in load balancer mode. */
|
||||
loadBalanced: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface WaitQueueMember {
|
||||
callback: Callback<Connection>;
|
||||
timer?: NodeJS.Timeout;
|
||||
[kCancelled]?: boolean;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const PoolState = Object.freeze({
|
||||
paused: 'paused',
|
||||
ready: 'ready',
|
||||
closed: 'closed'
|
||||
} as const);
|
||||
|
||||
/** @public */
|
||||
export interface CloseOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type ConnectionPoolEvents = {
|
||||
connectionPoolCreated(event: ConnectionPoolCreatedEvent): void;
|
||||
connectionPoolClosed(event: ConnectionPoolClosedEvent): void;
|
||||
connectionPoolCleared(event: ConnectionPoolClearedEvent): void;
|
||||
connectionCreated(event: ConnectionCreatedEvent): void;
|
||||
connectionReady(event: ConnectionReadyEvent): void;
|
||||
connectionClosed(event: ConnectionClosedEvent): void;
|
||||
connectionCheckOutStarted(event: ConnectionCheckOutStartedEvent): void;
|
||||
connectionCheckOutFailed(event: ConnectionCheckOutFailedEvent): void;
|
||||
connectionCheckedOut(event: ConnectionCheckedOutEvent): void;
|
||||
connectionCheckedIn(event: ConnectionCheckedInEvent): void;
|
||||
} & Omit<ConnectionEvents, 'close' | 'message'>;
|
||||
|
||||
/**
|
||||
* A pool of connections which dynamically resizes, and emit events related to pool activity
|
||||
* @internal
|
||||
*/
|
||||
export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
|
||||
options: Readonly<ConnectionPoolOptions>;
|
||||
/** @internal */
|
||||
[kPoolState]: typeof PoolState[keyof typeof PoolState];
|
||||
/** @internal */
|
||||
[kLogger]: Logger;
|
||||
/** @internal */
|
||||
[kConnections]: Denque<Connection>;
|
||||
/** @internal */
|
||||
[kPending]: number;
|
||||
/** @internal */
|
||||
[kCheckedOut]: number;
|
||||
/** @internal */
|
||||
[kMinPoolSizeTimer]?: NodeJS.Timeout;
|
||||
/**
|
||||
* An integer representing the SDAM generation of the pool
|
||||
* @internal
|
||||
*/
|
||||
[kGeneration]: number;
|
||||
/** A map of generations to service ids
|
||||
* @internal
|
||||
*/
|
||||
[kServiceGenerations]: Map<string, number>;
|
||||
/** @internal */
|
||||
[kConnectionCounter]: Generator<number>;
|
||||
/** @internal */
|
||||
[kCancellationToken]: CancellationToken;
|
||||
/** @internal */
|
||||
[kWaitQueue]: Denque<WaitQueueMember>;
|
||||
/** @internal */
|
||||
[kMetrics]: ConnectionPoolMetrics;
|
||||
/** @internal */
|
||||
[kProcessingWaitQueue]: boolean;
|
||||
|
||||
/**
|
||||
* Emitted when the connection pool is created.
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_POOL_CREATED = CONNECTION_POOL_CREATED;
|
||||
/**
|
||||
* Emitted once when the connection pool is closed
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_POOL_CLOSED = CONNECTION_POOL_CLOSED;
|
||||
/**
|
||||
* Emitted each time the connection pool is cleared and it's generation incremented
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_POOL_CLEARED = CONNECTION_POOL_CLEARED;
|
||||
/**
|
||||
* Emitted when a connection is created.
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_CREATED = CONNECTION_CREATED;
|
||||
/**
|
||||
* Emitted when a connection becomes established, and is ready to use
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_READY = CONNECTION_READY;
|
||||
/**
|
||||
* Emitted when a connection is closed
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_CLOSED = CONNECTION_CLOSED;
|
||||
/**
|
||||
* Emitted when an attempt to check out a connection begins
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_CHECK_OUT_STARTED = CONNECTION_CHECK_OUT_STARTED;
|
||||
/**
|
||||
* Emitted when an attempt to check out a connection fails
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_CHECK_OUT_FAILED = CONNECTION_CHECK_OUT_FAILED;
|
||||
/**
|
||||
* Emitted each time a connection is successfully checked out of the connection pool
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_CHECKED_OUT = CONNECTION_CHECKED_OUT;
|
||||
/**
|
||||
* Emitted each time a connection is successfully checked into the connection pool
|
||||
* @event
|
||||
*/
|
||||
static readonly CONNECTION_CHECKED_IN = CONNECTION_CHECKED_IN;
|
||||
|
||||
/** @internal */
|
||||
constructor(options: ConnectionPoolOptions) {
|
||||
super();
|
||||
|
||||
this.options = Object.freeze({
|
||||
...options,
|
||||
connectionType: Connection,
|
||||
maxPoolSize: options.maxPoolSize ?? 100,
|
||||
minPoolSize: options.minPoolSize ?? 0,
|
||||
maxConnecting: options.maxConnecting ?? 2,
|
||||
maxIdleTimeMS: options.maxIdleTimeMS ?? 0,
|
||||
waitQueueTimeoutMS: options.waitQueueTimeoutMS ?? 0,
|
||||
autoEncrypter: options.autoEncrypter,
|
||||
metadata: options.metadata
|
||||
});
|
||||
|
||||
if (this.options.minPoolSize > this.options.maxPoolSize) {
|
||||
throw new MongoInvalidArgumentError(
|
||||
'Connection pool minimum size must not be greater than maximum pool size'
|
||||
);
|
||||
}
|
||||
|
||||
this[kPoolState] = PoolState.paused;
|
||||
this[kLogger] = new Logger('ConnectionPool');
|
||||
this[kConnections] = new Denque();
|
||||
this[kPending] = 0;
|
||||
this[kCheckedOut] = 0;
|
||||
this[kMinPoolSizeTimer] = undefined;
|
||||
this[kGeneration] = 0;
|
||||
this[kServiceGenerations] = new Map();
|
||||
this[kConnectionCounter] = makeCounter(1);
|
||||
this[kCancellationToken] = new CancellationToken();
|
||||
this[kCancellationToken].setMaxListeners(Infinity);
|
||||
this[kWaitQueue] = new Denque();
|
||||
this[kMetrics] = new ConnectionPoolMetrics();
|
||||
this[kProcessingWaitQueue] = false;
|
||||
|
||||
process.nextTick(() => {
|
||||
this.emit(ConnectionPool.CONNECTION_POOL_CREATED, new ConnectionPoolCreatedEvent(this));
|
||||
this.ensureMinPoolSize();
|
||||
});
|
||||
}
|
||||
|
||||
/** The address of the endpoint the pool is connected to */
|
||||
get address(): string {
|
||||
return this.options.hostAddress.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the pool has been closed
|
||||
*
|
||||
* TODO(NODE-3263): We can remove this property once shell no longer needs it
|
||||
*/
|
||||
get closed(): boolean {
|
||||
return this[kPoolState] === PoolState.closed;
|
||||
}
|
||||
|
||||
/** An integer representing the SDAM generation of the pool */
|
||||
get generation(): number {
|
||||
return this[kGeneration];
|
||||
}
|
||||
|
||||
/** An integer expressing how many total connections (available + pending + in use) the pool currently has */
|
||||
get totalConnectionCount(): number {
|
||||
return (
|
||||
this.availableConnectionCount + this.pendingConnectionCount + this.currentCheckedOutCount
|
||||
);
|
||||
}
|
||||
|
||||
/** An integer expressing how many connections are currently available in the pool. */
|
||||
get availableConnectionCount(): number {
|
||||
return this[kConnections].length;
|
||||
}
|
||||
|
||||
get pendingConnectionCount(): number {
|
||||
return this[kPending];
|
||||
}
|
||||
|
||||
get currentCheckedOutCount(): number {
|
||||
return this[kCheckedOut];
|
||||
}
|
||||
|
||||
get waitQueueSize(): number {
|
||||
return this[kWaitQueue].length;
|
||||
}
|
||||
|
||||
get loadBalanced(): boolean {
|
||||
return this.options.loadBalanced;
|
||||
}
|
||||
|
||||
get serviceGenerations(): Map<string, number> {
|
||||
return this[kServiceGenerations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the metrics information for the pool when a wait queue timeout occurs.
|
||||
*/
|
||||
private waitQueueErrorMetrics(): string {
|
||||
return this[kMetrics].info(this.options.maxPoolSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the pool state to "ready"
|
||||
*/
|
||||
ready(): void {
|
||||
this[kPoolState] = PoolState.ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a connection out of this pool. The connection will continue to be tracked, but no reference to it
|
||||
* will be held by the pool. This means that if a connection is checked out it MUST be checked back in or
|
||||
* explicitly destroyed by the new owner.
|
||||
*/
|
||||
checkOut(callback: Callback<Connection>): void {
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CHECK_OUT_STARTED,
|
||||
new ConnectionCheckOutStartedEvent(this)
|
||||
);
|
||||
|
||||
if (this.closed) {
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
|
||||
new ConnectionCheckOutFailedEvent(this, 'poolClosed')
|
||||
);
|
||||
callback(new PoolClosedError(this));
|
||||
return;
|
||||
}
|
||||
|
||||
const waitQueueMember: WaitQueueMember = { callback };
|
||||
const waitQueueTimeoutMS = this.options.waitQueueTimeoutMS;
|
||||
if (waitQueueTimeoutMS) {
|
||||
waitQueueMember.timer = setTimeout(() => {
|
||||
waitQueueMember[kCancelled] = true;
|
||||
waitQueueMember.timer = undefined;
|
||||
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
|
||||
new ConnectionCheckOutFailedEvent(this, 'timeout')
|
||||
);
|
||||
waitQueueMember.callback(
|
||||
new WaitQueueTimeoutError(
|
||||
this.loadBalanced
|
||||
? this.waitQueueErrorMetrics()
|
||||
: 'Timed out while checking out a connection from connection pool',
|
||||
this.address
|
||||
)
|
||||
);
|
||||
}, waitQueueTimeoutMS);
|
||||
}
|
||||
|
||||
this[kWaitQueue].push(waitQueueMember);
|
||||
process.nextTick(() => this.processWaitQueue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a connection into the pool.
|
||||
*
|
||||
* @param connection - The connection to check in
|
||||
*/
|
||||
checkIn(connection: Connection): void {
|
||||
const poolClosed = this.closed;
|
||||
const stale = this.connectionIsStale(connection);
|
||||
const willDestroy = !!(poolClosed || stale || connection.closed);
|
||||
|
||||
if (!willDestroy) {
|
||||
connection.markAvailable();
|
||||
this[kConnections].unshift(connection);
|
||||
}
|
||||
|
||||
this[kCheckedOut]--;
|
||||
this.emit(ConnectionPool.CONNECTION_CHECKED_IN, new ConnectionCheckedInEvent(this, connection));
|
||||
|
||||
if (willDestroy) {
|
||||
const reason = connection.closed ? 'error' : poolClosed ? 'poolClosed' : 'stale';
|
||||
this.destroyConnection(connection, reason);
|
||||
}
|
||||
|
||||
process.nextTick(() => this.processWaitQueue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pool
|
||||
*
|
||||
* Pool reset is handled by incrementing the pool's generation count. Any existing connection of a
|
||||
* previous generation will eventually be pruned during subsequent checkouts.
|
||||
*/
|
||||
clear(serviceId?: ObjectId): void {
|
||||
if (this.loadBalanced && serviceId) {
|
||||
const sid = serviceId.toHexString();
|
||||
const generation = this.serviceGenerations.get(sid);
|
||||
// Only need to worry if the generation exists, since it should
|
||||
// always be there but typescript needs the check.
|
||||
if (generation == null) {
|
||||
// TODO(NODE-3483)
|
||||
throw new MongoRuntimeError('Service generations are required in load balancer mode.');
|
||||
} else {
|
||||
// Increment the generation for the service id.
|
||||
this.serviceGenerations.set(sid, generation + 1);
|
||||
}
|
||||
} else {
|
||||
this[kGeneration] += 1;
|
||||
}
|
||||
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_POOL_CLEARED,
|
||||
new ConnectionPoolClearedEvent(this, serviceId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Close the pool */
|
||||
close(callback: Callback<void>): void;
|
||||
close(options: CloseOptions, callback: Callback<void>): void;
|
||||
close(_options?: CloseOptions | Callback<void>, _cb?: Callback<void>): void {
|
||||
let options = _options as CloseOptions;
|
||||
const callback = (_cb ?? _options) as Callback<void>;
|
||||
if (typeof options === 'function') {
|
||||
options = {};
|
||||
}
|
||||
|
||||
options = Object.assign({ force: false }, options);
|
||||
if (this.closed) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
// immediately cancel any in-flight connections
|
||||
this[kCancellationToken].emit('cancel');
|
||||
|
||||
// drain the wait queue
|
||||
while (this.waitQueueSize) {
|
||||
const waitQueueMember = this[kWaitQueue].pop();
|
||||
if (waitQueueMember) {
|
||||
if (waitQueueMember.timer) {
|
||||
clearTimeout(waitQueueMember.timer);
|
||||
}
|
||||
if (!waitQueueMember[kCancelled]) {
|
||||
// TODO(NODE-3483): Replace with MongoConnectionPoolClosedError
|
||||
waitQueueMember.callback(new MongoRuntimeError('Connection pool closed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clear the min pool size timer
|
||||
const minPoolSizeTimer = this[kMinPoolSizeTimer];
|
||||
if (minPoolSizeTimer) {
|
||||
clearTimeout(minPoolSizeTimer);
|
||||
}
|
||||
|
||||
// end the connection counter
|
||||
if (typeof this[kConnectionCounter].return === 'function') {
|
||||
this[kConnectionCounter].return(undefined);
|
||||
}
|
||||
|
||||
// mark the pool as closed immediately
|
||||
this[kPoolState] = PoolState.closed;
|
||||
eachAsync<Connection>(
|
||||
this[kConnections].toArray(),
|
||||
(conn, cb) => {
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CLOSED,
|
||||
new ConnectionClosedEvent(this, conn, 'poolClosed')
|
||||
);
|
||||
conn.destroy(options, cb);
|
||||
},
|
||||
err => {
|
||||
this[kConnections].clear();
|
||||
this.emit(ConnectionPool.CONNECTION_POOL_CLOSED, new ConnectionPoolClosedEvent(this));
|
||||
callback(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a lambda with an implicitly checked out connection, checking that connection back in when the lambda
|
||||
* has completed by calling back.
|
||||
*
|
||||
* NOTE: please note the required signature of `fn`
|
||||
*
|
||||
* @remarks When in load balancer mode, connections can be pinned to cursors or transactions.
|
||||
* In these cases we pass the connection in to this method to ensure it is used and a new
|
||||
* connection is not checked out.
|
||||
*
|
||||
* @param conn - A pinned connection for use in load balancing mode.
|
||||
* @param fn - A function which operates on a managed connection
|
||||
* @param callback - The original callback
|
||||
*/
|
||||
withConnection(
|
||||
conn: Connection | undefined,
|
||||
fn: WithConnectionCallback,
|
||||
callback?: Callback<Connection>
|
||||
): void {
|
||||
if (conn) {
|
||||
// use the provided connection, and do _not_ check it in after execution
|
||||
fn(undefined, conn, (fnErr, result) => {
|
||||
if (typeof callback === 'function') {
|
||||
if (fnErr) {
|
||||
callback(fnErr);
|
||||
} else {
|
||||
callback(undefined, result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkOut((err, conn) => {
|
||||
// don't callback with `err` here, we might want to act upon it inside `fn`
|
||||
fn(err as MongoError, conn, (fnErr, result) => {
|
||||
if (typeof callback === 'function') {
|
||||
if (fnErr) {
|
||||
callback(fnErr);
|
||||
} else {
|
||||
callback(undefined, result);
|
||||
}
|
||||
}
|
||||
|
||||
if (conn) {
|
||||
this.checkIn(conn);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private destroyConnection(connection: Connection, reason: string) {
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CLOSED,
|
||||
new ConnectionClosedEvent(this, connection, reason)
|
||||
);
|
||||
|
||||
// destroy the connection
|
||||
process.nextTick(() => connection.destroy());
|
||||
}
|
||||
|
||||
private connectionIsStale(connection: Connection) {
|
||||
const serviceId = connection.serviceId;
|
||||
if (this.loadBalanced && serviceId) {
|
||||
const sid = serviceId.toHexString();
|
||||
const generation = this.serviceGenerations.get(sid);
|
||||
return connection.generation !== generation;
|
||||
}
|
||||
|
||||
return connection.generation !== this[kGeneration];
|
||||
}
|
||||
|
||||
private connectionIsIdle(connection: Connection) {
|
||||
return !!(this.options.maxIdleTimeMS && connection.idleTime > this.options.maxIdleTimeMS);
|
||||
}
|
||||
|
||||
private connectionIsPerished(connection: Connection) {
|
||||
const isStale = this.connectionIsStale(connection);
|
||||
const isIdle = this.connectionIsIdle(connection);
|
||||
if (!isStale && !isIdle && !connection.closed) {
|
||||
return false;
|
||||
}
|
||||
const reason = connection.closed ? 'error' : isStale ? 'stale' : 'idle';
|
||||
this.destroyConnection(connection, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
private createConnection(callback: Callback<Connection>) {
|
||||
const connectOptions: ConnectionOptions = {
|
||||
...this.options,
|
||||
id: this[kConnectionCounter].next().value,
|
||||
generation: this[kGeneration],
|
||||
cancellationToken: this[kCancellationToken]
|
||||
};
|
||||
|
||||
this[kPending]++;
|
||||
// This is our version of a "virtual" no-I/O connection as the spec requires
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CREATED,
|
||||
new ConnectionCreatedEvent(this, { id: connectOptions.id })
|
||||
);
|
||||
|
||||
connect(connectOptions, (err, connection) => {
|
||||
if (err || !connection) {
|
||||
this[kLogger].debug(`connection attempt failed with error [${JSON.stringify(err)}]`);
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// The pool might have closed since we started trying to create a connection
|
||||
if (this.closed) {
|
||||
this[kPending]--;
|
||||
connection.destroy({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// forward all events from the connection to the pool
|
||||
for (const event of [...APM_EVENTS, Connection.CLUSTER_TIME_RECEIVED]) {
|
||||
connection.on(event, (e: any) => this.emit(event, e));
|
||||
}
|
||||
|
||||
if (this.loadBalanced) {
|
||||
connection.on(Connection.PINNED, pinType => this[kMetrics].markPinned(pinType));
|
||||
connection.on(Connection.UNPINNED, pinType => this[kMetrics].markUnpinned(pinType));
|
||||
|
||||
const serviceId = connection.serviceId;
|
||||
if (serviceId) {
|
||||
let generation;
|
||||
const sid = serviceId.toHexString();
|
||||
if ((generation = this.serviceGenerations.get(sid))) {
|
||||
connection.generation = generation;
|
||||
} else {
|
||||
this.serviceGenerations.set(sid, 0);
|
||||
connection.generation = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection.markAvailable();
|
||||
this.emit(ConnectionPool.CONNECTION_READY, new ConnectionReadyEvent(this, connection));
|
||||
|
||||
callback(undefined, connection);
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
private ensureMinPoolSize() {
|
||||
const minPoolSize = this.options.minPoolSize;
|
||||
if (this.closed || minPoolSize === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.totalConnectionCount < minPoolSize &&
|
||||
this.pendingConnectionCount < this.options.maxConnecting
|
||||
) {
|
||||
// NOTE: ensureMinPoolSize should not try to get all the pending
|
||||
// connection permits because that potentially delays the availability of
|
||||
// the connection to a checkout request
|
||||
this.createConnection((err, connection) => {
|
||||
this[kPending]--;
|
||||
if (!err && connection) {
|
||||
this[kConnections].push(connection);
|
||||
process.nextTick(() => this.processWaitQueue());
|
||||
}
|
||||
this[kMinPoolSizeTimer] = setTimeout(() => this.ensureMinPoolSize(), 10);
|
||||
});
|
||||
} else {
|
||||
this[kMinPoolSizeTimer] = setTimeout(() => this.ensureMinPoolSize(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
private processWaitQueue() {
|
||||
if (this.closed || this[kProcessingWaitQueue]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[kProcessingWaitQueue] = true;
|
||||
|
||||
while (this.waitQueueSize) {
|
||||
const waitQueueMember = this[kWaitQueue].peekFront();
|
||||
if (!waitQueueMember) {
|
||||
this[kWaitQueue].shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (waitQueueMember[kCancelled]) {
|
||||
this[kWaitQueue].shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.availableConnectionCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const connection = this[kConnections].shift();
|
||||
if (!connection) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!this.connectionIsPerished(connection)) {
|
||||
this[kCheckedOut]++;
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CHECKED_OUT,
|
||||
new ConnectionCheckedOutEvent(this, connection)
|
||||
);
|
||||
if (waitQueueMember.timer) {
|
||||
clearTimeout(waitQueueMember.timer);
|
||||
}
|
||||
|
||||
this[kWaitQueue].shift();
|
||||
waitQueueMember.callback(undefined, connection);
|
||||
}
|
||||
}
|
||||
|
||||
const { maxPoolSize, maxConnecting } = this.options;
|
||||
while (
|
||||
this.waitQueueSize > 0 &&
|
||||
this.pendingConnectionCount < maxConnecting &&
|
||||
(maxPoolSize === 0 || this.totalConnectionCount < maxPoolSize)
|
||||
) {
|
||||
const waitQueueMember = this[kWaitQueue].shift();
|
||||
if (!waitQueueMember || waitQueueMember[kCancelled]) {
|
||||
continue;
|
||||
}
|
||||
this.createConnection((err, connection) => {
|
||||
this[kPending]--;
|
||||
if (waitQueueMember[kCancelled]) {
|
||||
if (!err && connection) {
|
||||
this[kConnections].push(connection);
|
||||
}
|
||||
} else {
|
||||
if (err) {
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
|
||||
new ConnectionCheckOutFailedEvent(this, err)
|
||||
);
|
||||
} else if (connection) {
|
||||
this[kCheckedOut]++;
|
||||
this.emit(
|
||||
ConnectionPool.CONNECTION_CHECKED_OUT,
|
||||
new ConnectionCheckedOutEvent(this, connection)
|
||||
);
|
||||
}
|
||||
|
||||
if (waitQueueMember.timer) {
|
||||
clearTimeout(waitQueueMember.timer);
|
||||
}
|
||||
waitQueueMember.callback(err, connection);
|
||||
}
|
||||
process.nextTick(() => this.processWaitQueue());
|
||||
});
|
||||
}
|
||||
this[kProcessingWaitQueue] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback provided to `withConnection`
|
||||
* @internal
|
||||
*
|
||||
* @param error - An error instance representing the error during the execution.
|
||||
* @param connection - The managed connection which was checked out of the pool.
|
||||
* @param callback - A function to call back after connection management is complete
|
||||
*/
|
||||
export type WithConnectionCallback = (
|
||||
error: MongoError | undefined,
|
||||
connection: Connection | undefined,
|
||||
callback: Callback<Connection>
|
||||
) => void;
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import type { ObjectId } from '../bson';
|
||||
import type { AnyError } from '../error';
|
||||
import type { Connection } from './connection';
|
||||
import type { ConnectionPool, ConnectionPoolOptions } from './connection_pool';
|
||||
|
||||
/**
|
||||
* The base export class for all monitoring events published from the connection pool
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionPoolMonitoringEvent {
|
||||
/** A timestamp when the event was created */
|
||||
time: Date;
|
||||
/** The address (host/port pair) of the pool */
|
||||
address: string;
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool) {
|
||||
this.time = new Date();
|
||||
this.address = pool.address;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection pool is created
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionPoolCreatedEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** The options used to create this connection pool */
|
||||
options?: ConnectionPoolOptions;
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool) {
|
||||
super(pool);
|
||||
this.options = pool.options;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection pool is closed
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionPoolClosedEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool) {
|
||||
super(pool);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection pool creates a new connection
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionCreatedEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** A monotonically increasing, per-pool id for the newly created connection */
|
||||
connectionId: number | '<monitor>';
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool, connection: { id: number | '<monitor>' }) {
|
||||
super(pool);
|
||||
this.connectionId = connection.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection is ready for use
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionReadyEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** The id of the connection */
|
||||
connectionId: number | '<monitor>';
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool, connection: Connection) {
|
||||
super(pool);
|
||||
this.connectionId = connection.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection is closed
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionClosedEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** The id of the connection */
|
||||
connectionId: number | '<monitor>';
|
||||
/** The reason the connection was closed */
|
||||
reason: string;
|
||||
serviceId?: ObjectId;
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool, connection: Connection, reason: string) {
|
||||
super(pool);
|
||||
this.connectionId = connection.id;
|
||||
this.reason = reason || 'unknown';
|
||||
this.serviceId = connection.serviceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a request to check a connection out begins
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionCheckOutStartedEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool) {
|
||||
super(pool);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a request to check a connection out fails
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionCheckOutFailedEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** The reason the attempt to check out failed */
|
||||
reason: AnyError | string;
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool, reason: AnyError | string) {
|
||||
super(pool);
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection is checked out of the connection pool
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionCheckedOutEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** The id of the connection */
|
||||
connectionId: number | '<monitor>';
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool, connection: Connection) {
|
||||
super(pool);
|
||||
this.connectionId = connection.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection is checked into the connection pool
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionCheckedInEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** The id of the connection */
|
||||
connectionId: number | '<monitor>';
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool, connection: Connection) {
|
||||
super(pool);
|
||||
this.connectionId = connection.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event published when a connection pool is cleared
|
||||
* @public
|
||||
* @category Event
|
||||
*/
|
||||
export class ConnectionPoolClearedEvent extends ConnectionPoolMonitoringEvent {
|
||||
/** @internal */
|
||||
serviceId?: ObjectId;
|
||||
|
||||
/** @internal */
|
||||
constructor(pool: ConnectionPool, serviceId?: ObjectId) {
|
||||
super(pool);
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { MongoDriverError } from '../error';
|
||||
import type { ConnectionPool } from './connection_pool';
|
||||
|
||||
/**
|
||||
* An error indicating a connection pool is closed
|
||||
* @category Error
|
||||
*/
|
||||
export class PoolClosedError extends MongoDriverError {
|
||||
/** The address of the connection pool */
|
||||
address: string;
|
||||
|
||||
constructor(pool: ConnectionPool) {
|
||||
super('Attempted to check out a connection from closed connection pool');
|
||||
this.address = pool.address;
|
||||
}
|
||||
|
||||
override get name(): string {
|
||||
return 'MongoPoolClosedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error thrown when a request to check out a connection times out
|
||||
* @category Error
|
||||
*/
|
||||
export class WaitQueueTimeoutError extends MongoDriverError {
|
||||
/** The address of the connection pool */
|
||||
address: string;
|
||||
|
||||
constructor(message: string, address: string) {
|
||||
super(message);
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
override get name(): string {
|
||||
return 'MongoWaitQueueTimeoutError';
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { Duplex, DuplexOptions } from 'stream';
|
||||
|
||||
import type { BSONSerializeOptions, Document } from '../bson';
|
||||
import { MongoDecompressionError, MongoParseError } from '../error';
|
||||
import type { ClientSession } from '../sessions';
|
||||
import { BufferPool, Callback } from '../utils';
|
||||
import { BinMsg, MessageHeader, Msg, Response, WriteProtocolMessageType } from './commands';
|
||||
import {
|
||||
compress,
|
||||
Compressor,
|
||||
CompressorName,
|
||||
decompress,
|
||||
uncompressibleCommands
|
||||
} from './wire_protocol/compression';
|
||||
import { OP_COMPRESSED, OP_MSG } from './wire_protocol/constants';
|
||||
|
||||
const MESSAGE_HEADER_SIZE = 16;
|
||||
const COMPRESSION_DETAILS_SIZE = 9; // originalOpcode + uncompressedSize, compressorID
|
||||
|
||||
const kDefaultMaxBsonMessageSize = 1024 * 1024 * 16 * 4;
|
||||
/** @internal */
|
||||
const kBuffer = Symbol('buffer');
|
||||
|
||||
/** @internal */
|
||||
export interface MessageStreamOptions extends DuplexOptions {
|
||||
maxBsonMessageSize?: number;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface OperationDescription extends BSONSerializeOptions {
|
||||
started: number;
|
||||
cb: Callback<Document>;
|
||||
command: boolean;
|
||||
documentsReturnedIn?: string;
|
||||
noResponse: boolean;
|
||||
raw: boolean;
|
||||
requestId: number;
|
||||
session?: ClientSession;
|
||||
socketTimeoutOverride?: boolean;
|
||||
agreedCompressor?: CompressorName;
|
||||
zlibCompressionLevel?: number;
|
||||
$clusterTime?: Document;
|
||||
}
|
||||
|
||||
/**
|
||||
* A duplex stream that is capable of reading and writing raw wire protocol messages, with
|
||||
* support for optional compression
|
||||
* @internal
|
||||
*/
|
||||
export class MessageStream extends Duplex {
|
||||
/** @internal */
|
||||
maxBsonMessageSize: number;
|
||||
/** @internal */
|
||||
[kBuffer]: BufferPool;
|
||||
/** @internal */
|
||||
isMonitoringConnection = false;
|
||||
|
||||
constructor(options: MessageStreamOptions = {}) {
|
||||
super(options);
|
||||
this.maxBsonMessageSize = options.maxBsonMessageSize || kDefaultMaxBsonMessageSize;
|
||||
this[kBuffer] = new BufferPool();
|
||||
}
|
||||
|
||||
get buffer(): BufferPool {
|
||||
return this[kBuffer];
|
||||
}
|
||||
|
||||
override _write(chunk: Buffer, _: unknown, callback: Callback<Buffer>): void {
|
||||
this[kBuffer].append(chunk);
|
||||
processIncomingData(this, callback);
|
||||
}
|
||||
|
||||
override _read(/* size */): void {
|
||||
// NOTE: This implementation is empty because we explicitly push data to be read
|
||||
// when `writeMessage` is called.
|
||||
return;
|
||||
}
|
||||
|
||||
writeCommand(
|
||||
command: WriteProtocolMessageType,
|
||||
operationDescription: OperationDescription
|
||||
): void {
|
||||
// TODO: agreed compressor should live in `StreamDescription`
|
||||
const compressorName: CompressorName =
|
||||
operationDescription && operationDescription.agreedCompressor
|
||||
? operationDescription.agreedCompressor
|
||||
: 'none';
|
||||
if (compressorName === 'none' || !canCompress(command)) {
|
||||
const data = command.toBin();
|
||||
this.push(Array.isArray(data) ? Buffer.concat(data) : data);
|
||||
return;
|
||||
}
|
||||
// otherwise, compress the message
|
||||
const concatenatedOriginalCommandBuffer = Buffer.concat(command.toBin());
|
||||
const messageToBeCompressed = concatenatedOriginalCommandBuffer.slice(MESSAGE_HEADER_SIZE);
|
||||
|
||||
// Extract information needed for OP_COMPRESSED from the uncompressed message
|
||||
const originalCommandOpCode = concatenatedOriginalCommandBuffer.readInt32LE(12);
|
||||
|
||||
// Compress the message body
|
||||
compress({ options: operationDescription }, messageToBeCompressed, (err, compressedMessage) => {
|
||||
if (err || !compressedMessage) {
|
||||
operationDescription.cb(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the msgHeader of OP_COMPRESSED
|
||||
const msgHeader = Buffer.alloc(MESSAGE_HEADER_SIZE);
|
||||
msgHeader.writeInt32LE(
|
||||
MESSAGE_HEADER_SIZE + COMPRESSION_DETAILS_SIZE + compressedMessage.length,
|
||||
0
|
||||
); // messageLength
|
||||
msgHeader.writeInt32LE(command.requestId, 4); // requestID
|
||||
msgHeader.writeInt32LE(0, 8); // responseTo (zero)
|
||||
msgHeader.writeInt32LE(OP_COMPRESSED, 12); // opCode
|
||||
|
||||
// Create the compression details of OP_COMPRESSED
|
||||
const compressionDetails = Buffer.alloc(COMPRESSION_DETAILS_SIZE);
|
||||
compressionDetails.writeInt32LE(originalCommandOpCode, 0); // originalOpcode
|
||||
compressionDetails.writeInt32LE(messageToBeCompressed.length, 4); // Size of the uncompressed compressedMessage, excluding the MsgHeader
|
||||
compressionDetails.writeUInt8(Compressor[compressorName], 8); // compressorID
|
||||
this.push(Buffer.concat([msgHeader, compressionDetails, compressedMessage]));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return whether a command contains an uncompressible command term
|
||||
// Will return true if command contains no uncompressible command terms
|
||||
function canCompress(command: WriteProtocolMessageType) {
|
||||
const commandDoc = command instanceof Msg ? command.command : command.query;
|
||||
const commandName = Object.keys(commandDoc)[0];
|
||||
return !uncompressibleCommands.has(commandName);
|
||||
}
|
||||
|
||||
function processIncomingData(stream: MessageStream, callback: Callback<Buffer>) {
|
||||
const buffer = stream[kBuffer];
|
||||
if (buffer.length < 4) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeOfMessage = buffer.peek(4).readInt32LE();
|
||||
if (sizeOfMessage < 0) {
|
||||
callback(new MongoParseError(`Invalid message size: ${sizeOfMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sizeOfMessage > stream.maxBsonMessageSize) {
|
||||
callback(
|
||||
new MongoParseError(
|
||||
`Invalid message size: ${sizeOfMessage}, max allowed: ${stream.maxBsonMessageSize}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sizeOfMessage > buffer.length) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const message = buffer.read(sizeOfMessage);
|
||||
const messageHeader: MessageHeader = {
|
||||
length: message.readInt32LE(0),
|
||||
requestId: message.readInt32LE(4),
|
||||
responseTo: message.readInt32LE(8),
|
||||
opCode: message.readInt32LE(12)
|
||||
};
|
||||
|
||||
const monitorHasAnotherHello = () => {
|
||||
if (stream.isMonitoringConnection) {
|
||||
// Can we read the next message size?
|
||||
if (buffer.length >= 4) {
|
||||
const sizeOfMessage = buffer.peek(4).readInt32LE();
|
||||
if (sizeOfMessage <= buffer.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let ResponseType = messageHeader.opCode === OP_MSG ? BinMsg : Response;
|
||||
if (messageHeader.opCode !== OP_COMPRESSED) {
|
||||
const messageBody = message.slice(MESSAGE_HEADER_SIZE);
|
||||
|
||||
// If we are a monitoring connection message stream and
|
||||
// there is more in the buffer that can be read, skip processing since we
|
||||
// want the last hello command response that is in the buffer.
|
||||
if (monitorHasAnotherHello()) {
|
||||
processIncomingData(stream, callback);
|
||||
} else {
|
||||
stream.emit('message', new ResponseType(message, messageHeader, messageBody));
|
||||
|
||||
if (buffer.length >= 4) {
|
||||
processIncomingData(stream, callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
messageHeader.fromCompressed = true;
|
||||
messageHeader.opCode = message.readInt32LE(MESSAGE_HEADER_SIZE);
|
||||
messageHeader.length = message.readInt32LE(MESSAGE_HEADER_SIZE + 4);
|
||||
const compressorID: Compressor = message[MESSAGE_HEADER_SIZE + 8] as Compressor;
|
||||
const compressedBuffer = message.slice(MESSAGE_HEADER_SIZE + 9);
|
||||
|
||||
// recalculate based on wrapped opcode
|
||||
ResponseType = messageHeader.opCode === OP_MSG ? BinMsg : Response;
|
||||
decompress(compressorID, compressedBuffer, (err, messageBody) => {
|
||||
if (err || !messageBody) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageBody.length !== messageHeader.length) {
|
||||
callback(
|
||||
new MongoDecompressionError('Message body and message header must be the same length')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are a monitoring connection message stream and
|
||||
// there is more in the buffer that can be read, skip processing since we
|
||||
// want the last hello command response that is in the buffer.
|
||||
if (monitorHasAnotherHello()) {
|
||||
processIncomingData(stream, callback);
|
||||
} else {
|
||||
stream.emit('message', new ResponseType(message, messageHeader, messageBody));
|
||||
|
||||
if (buffer.length >= 4) {
|
||||
processIncomingData(stream, callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/** @internal */
|
||||
export class ConnectionPoolMetrics {
|
||||
static readonly TXN = 'txn' as const;
|
||||
static readonly CURSOR = 'cursor' as const;
|
||||
static readonly OTHER = 'other' as const;
|
||||
|
||||
txnConnections = 0;
|
||||
cursorConnections = 0;
|
||||
otherConnections = 0;
|
||||
|
||||
/**
|
||||
* Mark a connection as pinned for a specific operation.
|
||||
*/
|
||||
markPinned(pinType: string): void {
|
||||
if (pinType === ConnectionPoolMetrics.TXN) {
|
||||
this.txnConnections += 1;
|
||||
} else if (pinType === ConnectionPoolMetrics.CURSOR) {
|
||||
this.cursorConnections += 1;
|
||||
} else {
|
||||
this.otherConnections += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark a connection as pinned for an operation.
|
||||
*/
|
||||
markUnpinned(pinType: string): void {
|
||||
if (pinType === ConnectionPoolMetrics.TXN) {
|
||||
this.txnConnections -= 1;
|
||||
} else if (pinType === ConnectionPoolMetrics.CURSOR) {
|
||||
this.cursorConnections -= 1;
|
||||
} else {
|
||||
this.otherConnections -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return information about the cmap metrics as a string.
|
||||
*/
|
||||
info(maxPoolSize: number): string {
|
||||
return (
|
||||
'Timed out while checking out a connection from connection pool: ' +
|
||||
`maxPoolSize: ${maxPoolSize}, ` +
|
||||
`connections in use by cursors: ${this.cursorConnections}, ` +
|
||||
`connections in use by transactions: ${this.txnConnections}, ` +
|
||||
`connections in use by other operations: ${this.otherConnections}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the metrics to the initial values.
|
||||
*/
|
||||
reset(): void {
|
||||
this.txnConnections = 0;
|
||||
this.cursorConnections = 0;
|
||||
this.otherConnections = 0;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type { Document } from '../bson';
|
||||
import { ServerType } from '../sdam/common';
|
||||
import { parseServerType } from '../sdam/server_description';
|
||||
import type { CompressorName } from './wire_protocol/compression';
|
||||
|
||||
const RESPONSE_FIELDS = [
|
||||
'minWireVersion',
|
||||
'maxWireVersion',
|
||||
'maxBsonObjectSize',
|
||||
'maxMessageSizeBytes',
|
||||
'maxWriteBatchSize',
|
||||
'logicalSessionTimeoutMinutes'
|
||||
] as const;
|
||||
|
||||
/** @public */
|
||||
export interface StreamDescriptionOptions {
|
||||
compressors?: CompressorName[];
|
||||
logicalSessionTimeoutMinutes?: number;
|
||||
loadBalanced: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class StreamDescription {
|
||||
address: string;
|
||||
type: string;
|
||||
minWireVersion?: number;
|
||||
maxWireVersion?: number;
|
||||
maxBsonObjectSize: number;
|
||||
maxMessageSizeBytes: number;
|
||||
maxWriteBatchSize: number;
|
||||
compressors: CompressorName[];
|
||||
compressor?: CompressorName;
|
||||
logicalSessionTimeoutMinutes?: number;
|
||||
loadBalanced: boolean;
|
||||
|
||||
__nodejs_mock_server__?: boolean;
|
||||
|
||||
zlibCompressionLevel?: number;
|
||||
|
||||
constructor(address: string, options?: StreamDescriptionOptions) {
|
||||
this.address = address;
|
||||
this.type = ServerType.Unknown;
|
||||
this.minWireVersion = undefined;
|
||||
this.maxWireVersion = undefined;
|
||||
this.maxBsonObjectSize = 16777216;
|
||||
this.maxMessageSizeBytes = 48000000;
|
||||
this.maxWriteBatchSize = 100000;
|
||||
this.logicalSessionTimeoutMinutes = options?.logicalSessionTimeoutMinutes;
|
||||
this.loadBalanced = !!options?.loadBalanced;
|
||||
this.compressors =
|
||||
options && options.compressors && Array.isArray(options.compressors)
|
||||
? options.compressors
|
||||
: [];
|
||||
}
|
||||
|
||||
receiveResponse(response: Document | null): void {
|
||||
if (response == null) {
|
||||
return;
|
||||
}
|
||||
this.type = parseServerType(response);
|
||||
for (const field of RESPONSE_FIELDS) {
|
||||
if (response[field] != null) {
|
||||
this[field] = response[field];
|
||||
}
|
||||
|
||||
// testing case
|
||||
if ('__nodejs_mock_server__' in response) {
|
||||
this.__nodejs_mock_server__ = response['__nodejs_mock_server__'];
|
||||
}
|
||||
}
|
||||
|
||||
if (response.compression) {
|
||||
this.compressor = this.compressors.filter(c => response.compression?.includes(c))[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
import { LEGACY_HELLO_COMMAND } from '../../constants';
|
||||
import { PKG_VERSION, Snappy, ZStandard } from '../../deps';
|
||||
import { MongoDecompressionError, MongoInvalidArgumentError } from '../../error';
|
||||
import type { Callback } from '../../utils';
|
||||
import type { OperationDescription } from '../message_stream';
|
||||
|
||||
/** @public */
|
||||
export const Compressor = Object.freeze({
|
||||
none: 0,
|
||||
snappy: 1,
|
||||
zlib: 2,
|
||||
zstd: 3
|
||||
} as const);
|
||||
|
||||
/** @public */
|
||||
export type Compressor = typeof Compressor[CompressorName];
|
||||
|
||||
/** @public */
|
||||
export type CompressorName = keyof typeof Compressor;
|
||||
|
||||
export const uncompressibleCommands = new Set([
|
||||
LEGACY_HELLO_COMMAND,
|
||||
'saslStart',
|
||||
'saslContinue',
|
||||
'getnonce',
|
||||
'authenticate',
|
||||
'createUser',
|
||||
'updateUser',
|
||||
'copydbSaslStart',
|
||||
'copydbgetnonce',
|
||||
'copydb'
|
||||
]);
|
||||
|
||||
const MAX_COMPRESSOR_ID = 3;
|
||||
const ZSTD_COMPRESSION_LEVEL = 3;
|
||||
|
||||
// Facilitate compressing a message using an agreed compressor
|
||||
export function compress(
|
||||
self: { options: OperationDescription & zlib.ZlibOptions },
|
||||
dataToBeCompressed: Buffer,
|
||||
callback: Callback<Buffer>
|
||||
): void {
|
||||
const zlibOptions = {} as zlib.ZlibOptions;
|
||||
switch (self.options.agreedCompressor) {
|
||||
case 'snappy': {
|
||||
if ('kModuleError' in Snappy) {
|
||||
return callback(Snappy['kModuleError']);
|
||||
}
|
||||
|
||||
if (Snappy[PKG_VERSION].major <= 6) {
|
||||
Snappy.compress(dataToBeCompressed, callback);
|
||||
} else {
|
||||
Snappy.compress(dataToBeCompressed).then(
|
||||
buffer => callback(undefined, buffer),
|
||||
error => callback(error)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'zlib':
|
||||
// Determine zlibCompressionLevel
|
||||
if (self.options.zlibCompressionLevel) {
|
||||
zlibOptions.level = self.options.zlibCompressionLevel;
|
||||
}
|
||||
zlib.deflate(dataToBeCompressed, zlibOptions, callback as zlib.CompressCallback);
|
||||
break;
|
||||
case 'zstd':
|
||||
if ('kModuleError' in ZStandard) {
|
||||
return callback(ZStandard['kModuleError']);
|
||||
}
|
||||
ZStandard.compress(dataToBeCompressed, ZSTD_COMPRESSION_LEVEL).then(
|
||||
buffer => callback(undefined, buffer),
|
||||
error => callback(error)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new MongoInvalidArgumentError(
|
||||
`Unknown compressor ${self.options.agreedCompressor} failed to compress`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Decompress a message using the given compressor
|
||||
export function decompress(
|
||||
compressorID: Compressor,
|
||||
compressedData: Buffer,
|
||||
callback: Callback<Buffer>
|
||||
): void {
|
||||
if (compressorID < 0 || compressorID > MAX_COMPRESSOR_ID) {
|
||||
throw new MongoDecompressionError(
|
||||
`Server sent message compressed using an unsupported compressor. (Received compressor ID ${compressorID})`
|
||||
);
|
||||
}
|
||||
|
||||
switch (compressorID) {
|
||||
case Compressor.snappy: {
|
||||
if ('kModuleError' in Snappy) {
|
||||
return callback(Snappy['kModuleError']);
|
||||
}
|
||||
|
||||
if (Snappy[PKG_VERSION].major <= 6) {
|
||||
Snappy.uncompress(compressedData, { asBuffer: true }, callback);
|
||||
} else {
|
||||
Snappy.uncompress(compressedData, { asBuffer: true }).then(
|
||||
buffer => callback(undefined, buffer),
|
||||
error => callback(error)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Compressor.zstd: {
|
||||
if ('kModuleError' in ZStandard) {
|
||||
return callback(ZStandard['kModuleError']);
|
||||
}
|
||||
|
||||
ZStandard.decompress(compressedData).then(
|
||||
buffer => callback(undefined, buffer),
|
||||
error => callback(error)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case Compressor.zlib:
|
||||
zlib.inflate(compressedData, callback as zlib.CompressCallback);
|
||||
break;
|
||||
default:
|
||||
callback(undefined, compressedData);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export const MIN_SUPPORTED_SERVER_VERSION = '3.6';
|
||||
export const MAX_SUPPORTED_SERVER_VERSION = '6.0';
|
||||
export const MIN_SUPPORTED_WIRE_VERSION = 6;
|
||||
export const MAX_SUPPORTED_WIRE_VERSION = 17;
|
||||
export const OP_REPLY = 1;
|
||||
export const OP_UPDATE = 2001;
|
||||
export const OP_INSERT = 2002;
|
||||
export const OP_QUERY = 2004;
|
||||
export const OP_DELETE = 2006;
|
||||
export const OP_COMPRESSED = 2012;
|
||||
export const OP_MSG = 2013;
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type { Document } from '../../bson';
|
||||
import { MongoInvalidArgumentError } from '../../error';
|
||||
import type { ReadPreferenceLike } from '../../read_preference';
|
||||
import { ReadPreference } from '../../read_preference';
|
||||
import { ServerType } from '../../sdam/common';
|
||||
import type { Server } from '../../sdam/server';
|
||||
import type { ServerDescription } from '../../sdam/server_description';
|
||||
import type { Topology } from '../../sdam/topology';
|
||||
import { TopologyDescription } from '../../sdam/topology_description';
|
||||
import type { OpQueryOptions } from '../commands';
|
||||
import type { CommandOptions, Connection } from '../connection';
|
||||
|
||||
export interface ReadPreferenceOption {
|
||||
readPreference?: ReadPreferenceLike;
|
||||
}
|
||||
|
||||
export function getReadPreference(cmd: Document, options?: ReadPreferenceOption): ReadPreference {
|
||||
// Default to command version of the readPreference
|
||||
let readPreference = cmd.readPreference || ReadPreference.primary;
|
||||
// If we have an option readPreference override the command one
|
||||
if (options?.readPreference) {
|
||||
readPreference = options.readPreference;
|
||||
}
|
||||
|
||||
if (typeof readPreference === 'string') {
|
||||
readPreference = ReadPreference.fromString(readPreference);
|
||||
}
|
||||
|
||||
if (!(readPreference instanceof ReadPreference)) {
|
||||
throw new MongoInvalidArgumentError(
|
||||
'Option "readPreference" must be a ReadPreference instance'
|
||||
);
|
||||
}
|
||||
|
||||
return readPreference;
|
||||
}
|
||||
|
||||
export function applyCommonQueryOptions(
|
||||
queryOptions: OpQueryOptions,
|
||||
options: CommandOptions
|
||||
): CommandOptions {
|
||||
Object.assign(queryOptions, {
|
||||
raw: typeof options.raw === 'boolean' ? options.raw : false,
|
||||
promoteLongs: typeof options.promoteLongs === 'boolean' ? options.promoteLongs : true,
|
||||
promoteValues: typeof options.promoteValues === 'boolean' ? options.promoteValues : true,
|
||||
promoteBuffers: typeof options.promoteBuffers === 'boolean' ? options.promoteBuffers : false,
|
||||
bsonRegExp: typeof options.bsonRegExp === 'boolean' ? options.bsonRegExp : false,
|
||||
enableUtf8Validation:
|
||||
typeof options.enableUtf8Validation === 'boolean' ? options.enableUtf8Validation : true
|
||||
});
|
||||
|
||||
if (options.session) {
|
||||
queryOptions.session = options.session;
|
||||
}
|
||||
|
||||
return queryOptions;
|
||||
}
|
||||
|
||||
export function isSharded(topologyOrServer?: Topology | Server | Connection): boolean {
|
||||
if (topologyOrServer == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (topologyOrServer.description && topologyOrServer.description.type === ServerType.Mongos) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NOTE: This is incredibly inefficient, and should be removed once command construction
|
||||
// happens based on `Server` not `Topology`.
|
||||
if (topologyOrServer.description && topologyOrServer.description instanceof TopologyDescription) {
|
||||
const servers: ServerDescription[] = Array.from(topologyOrServer.description.servers.values());
|
||||
return servers.some((server: ServerDescription) => server.type === ServerType.Mongos);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user