something is working

This commit is contained in:
quoije
2022-09-15 01:55:36 -04:00
parent a0aa251c27
commit f38e69883c
1123 changed files with 251731 additions and 11 deletions
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}