blob: 0f02b73659642ed16c738bc4d6fb4dc9b7c8145a [file]
/*
* Copyright DataStax, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const https = require('https');
const fs = require('fs').promises;
const util = require('util');
const AdmZip = require('adm-zip');
const errors = require('../../errors');
const utils = require('../../utils');
const { DsePlainTextAuthProvider } = require('../../auth');
/**
* When the user sets the cloud options, it uses the secure bundle or endpoint to access the metadata service and
* setting the connection options
* @param {ClientOptions} options
* @returns {Promise<void>}
*/
async function init(options) {
if (!options.cloud) {
return;
}
const cloudOptions = new CloudOptions(options);
await parseZipFile(cloudOptions);
await getMetadataServiceInfoAsync(cloudOptions);
if (!cloudOptions.clientOptions.sslOptions.checkServerIdentity) {
// With SNI enabled, hostname and CN will not match
// Host name and CN was validated as part of the initial HTTPS request
cloudOptions.clientOptions.sslOptions.checkServerIdentity = () => undefined;
}
}
class CloudOptions {
constructor(clientOptions) {
this.clientOptions = clientOptions;
if (clientOptions.cloud.secureConnectBundle) {
this.secureConnectBundle = clientOptions.cloud.secureConnectBundle;
this.serviceUrl = null;
} else {
this.serviceUrl = clientOptions.cloud.endpoint;
}
this.contactPoints = null;
this.localDataCenter = null;
}
/**
* The sslOptions in the client options from a given map.
* @param {Map<String, Buffer>} zipEntries
*/
setSslOptions(zipEntries) {
this.clientOptions.sslOptions = Object.assign({
ca: [zipEntries.get('ca.crt') ],
cert: zipEntries.get('cert'),
key: zipEntries.get('key'),
rejectUnauthorized: true
}, this.clientOptions.sslOptions);
}
/**
*
* @param username
* @param password
*/
setAuthProvider(username, password) {
if (!username || !password) {
return;
}
if (this.clientOptions.authProvider) {
// There is an auth provider set by the user
return;
}
this.clientOptions.authProvider = new DsePlainTextAuthProvider(username, password);
}
}
/**
* @param {CloudOptions} cloudOptions
* @returns {Promise<void>}
*/
async function parseZipFile(cloudOptions) {
if (cloudOptions.serviceUrl) {
// Service url already was provided
return;
}
if (!cloudOptions.secureConnectBundle) {
throw new TypeError('secureConnectBundle must be provided');
}
const data = await fs.readFile(cloudOptions.secureConnectBundle);
const zip = new AdmZip(data);
const zipEntries = new Map(zip.getEntries().map(e => [e.entryName, e.getData()]));
if (!zipEntries.get('config.json')) {
throw new TypeError('Config file must be contained in secure bundle');
}
const config = JSON.parse(zipEntries.get('config.json').toString('utf8'));
if (!config['host'] || !config['port']) {
throw new TypeError('Config file must include host and port information');
}
cloudOptions.serviceUrl = `${config['host']}:${config['port']}/metadata`;
cloudOptions.setSslOptions(zipEntries);
cloudOptions.setAuthProvider(config.username, config.password);
}
/**
* Gets the information retrieved from the metadata service.
* Invokes the callback with {proxyAddress, localDataCenter, contactPoints} as result
* @param {CloudOptions} cloudOptions
* @param {Function} callback
*/
function getMetadataServiceInfo(cloudOptions, callback) {
const regex = /^(.+?):(\d+)(.*)$/;
const matches = regex.exec(cloudOptions.serviceUrl);
callback = utils.callbackOnce(callback);
if (!matches || matches.length !== 4) {
throw new TypeError('url should be composed of host, port number and path, without scheme');
}
const requestOptions = Object.assign({
hostname: matches[1],
port: matches[2],
path: matches[3] || undefined,
timeout: cloudOptions.clientOptions.socketOptions.connectTimeout
}, cloudOptions.clientOptions.sslOptions);
const req = https.get(requestOptions, res => {
let data = '';
res
.on('data', chunk => data += chunk.toString())
.on('end', () => {
if (res.statusCode !== 200) {
return callback(getServiceRequestError(new Error(`Obtained http status ${res.statusCode}`), requestOptions));
}
let message;
try {
message = JSON.parse(data);
if (!message || !message['contact_info']) {
throw new TypeError('contact_info should be defined in response');
}
} catch (err) {
return callback(getServiceRequestError(err, requestOptions, true));
}
const contactInfo = message['contact_info'];
// Set the connect options
cloudOptions.clientOptions.contactPoints = contactInfo['contact_points'];
cloudOptions.clientOptions.localDataCenter = contactInfo['local_dc'];
cloudOptions.clientOptions.sni = { address: contactInfo['sni_proxy_address'] };
callback();
});
});
req.on('error', err => callback(getServiceRequestError(err, requestOptions)));
// We need to both set the timeout in the requestOptions and invoke ClientRequest#setTimeout()
// to handle all possible scenarios, for some reason... (tested with one OR the other and didn't fully work)
// Setting the also the timeout handler, aborting will emit 'error' and close
req.setTimeout(cloudOptions.clientOptions.socketOptions.connectTimeout, () => req.abort());
}
const getMetadataServiceInfoAsync = util.promisify(getMetadataServiceInfo);
/**
* Returns an Error that wraps the inner error obtained while fetching metadata information.
* @private
*/
function getServiceRequestError(err, requestOptions, isParsingError) {
const message = isParsingError
? 'There was an error while parsing the metadata service information'
: 'There was an error fetching the metadata information';
const url = `${requestOptions.hostname}:${requestOptions.port}${(requestOptions.path) ? requestOptions.path : '/'}`;
return new errors.NoHostAvailableError({ [url] : err }, message);
}
exports.init = init;