You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
338 lines
10 KiB
JavaScript
338 lines
10 KiB
JavaScript
2 years ago
|
/*
|
||
|
* 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');
|
||
|
const util = require('util');
|
||
|
const AdmZip = require('adm-zip');
|
||
|
const { URL } = require('url');
|
||
|
|
||
|
const errors = require('../../errors');
|
||
|
const utils = require('../../utils');
|
||
|
const { DsePlainTextAuthProvider, NoAuthProvider } = require('../../auth');
|
||
|
|
||
|
// Use the callback-based method fs.readFile() instead of fs.promises as we have to support Node.js 8+
|
||
|
const readFile = util.promisify(fs.readFile);
|
||
|
|
||
|
/**
|
||
|
* 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 (uuid) and CN will not match
|
||
|
// Use a custom validation function to validate against the proxy address.
|
||
|
// Note: this function is only called if the certificate passed all other checks, like CA validation.
|
||
|
cloudOptions.clientOptions.sslOptions.checkServerIdentity = (_, cert) =>
|
||
|
checkServerIdentity(cert, cloudOptions.clientOptions.sni.address);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
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;
|
||
|
}
|
||
|
// Include a log emitter to enable logging within the cloud connection logic
|
||
|
this.logEmitter = clientOptions.logEmitter;
|
||
|
|
||
|
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 && !(this.clientOptions.authProvider instanceof NoAuthProvider)) {
|
||
|
// 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 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 = '';
|
||
|
|
||
|
utils.log('verbose', `Connected to metadata service with SSL/TLS protocol ${res.socket.getProtocol()}`, {}, cloudOptions);
|
||
|
|
||
|
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);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {{subject: {CN: string}, subjectaltname: string?}} cert A certificate object as defined by
|
||
|
* TLS module https://nodejs.org/docs/latest-v12.x/api/tls.html#tls_certificate_object
|
||
|
* @param {string} sniAddress
|
||
|
* @returns {Error|undefined} Similar to tls.checkServerIdentity() returns an Error object, populating it with reason,
|
||
|
* host, and cert on failure. Otherwise, it returns undefined.
|
||
|
* @internal
|
||
|
* @ignore
|
||
|
*/
|
||
|
function checkServerIdentity(cert, sniAddress) {
|
||
|
// Based on logic defined by the Node.js Core module
|
||
|
// https://github.com/nodejs/node/blob/ff48009fefcecedfee2c6ff1719e5be3f6969049/lib/tls.js#L212-L290
|
||
|
|
||
|
// SNI address is composed by hostname and port
|
||
|
const hostName = sniAddress.split(':')[0];
|
||
|
const altNames = cert.subjectaltname;
|
||
|
const cn = cert.subject.CN;
|
||
|
|
||
|
if (hostName === cn) {
|
||
|
// quick check based on common name
|
||
|
return undefined;
|
||
|
}
|
||
|
|
||
|
const parsedAltNames = [];
|
||
|
if (altNames) {
|
||
|
for (const name of altNames.split(', ')) {
|
||
|
if (name.startsWith('DNS:')) {
|
||
|
parsedAltNames.push(name.slice(4));
|
||
|
} else if (name.startsWith('URI:')) {
|
||
|
parsedAltNames.push(new URL(name.slice(4)).hostname);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const hostParts = hostName.split('.');
|
||
|
const wildcard = (pattern) => checkParts(hostParts, pattern);
|
||
|
|
||
|
let valid;
|
||
|
if (parsedAltNames.length > 0) {
|
||
|
valid = parsedAltNames.some(wildcard);
|
||
|
} else {
|
||
|
// Use the common name
|
||
|
valid = wildcard(cn);
|
||
|
}
|
||
|
|
||
|
if (!valid) {
|
||
|
const error = new Error(`Host: ${hostName} is not cert's CN/altnames: ${cn} / ${altNames}`);
|
||
|
error.reason = error.message;
|
||
|
error.host = hostName;
|
||
|
error.cert = cert;
|
||
|
return error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Simplified version of Node.js tls core lib check() function
|
||
|
* https://github.com/nodejs/node/blob/ff48009fefcecedfee2c6ff1719e5be3f6969049/lib/tls.js#L148-L209
|
||
|
* @private
|
||
|
* @returns {boolean}
|
||
|
*/
|
||
|
function checkParts(hostParts, pattern) {
|
||
|
// Empty strings, null, undefined, etc. never match.
|
||
|
if (!pattern) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
const patternParts = pattern.split('.');
|
||
|
|
||
|
if (hostParts.length !== patternParts.length) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Check host parts from right to left first.
|
||
|
for (let i = hostParts.length - 1; i > 0; i -= 1) {
|
||
|
if (hostParts[i] !== patternParts[i]) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const hostSubdomain = hostParts[0];
|
||
|
const patternSubdomain = patternParts[0];
|
||
|
const patternSubdomainParts = patternSubdomain.split('*');
|
||
|
|
||
|
// Short-circuit when the subdomain does not contain a wildcard.
|
||
|
// RFC 6125 does not allow wildcard substitution for components
|
||
|
// containing IDNA A-labels (Punycode) so match those verbatim.
|
||
|
if (patternSubdomainParts.length === 1 || patternSubdomain.includes('xn--')) {
|
||
|
return hostSubdomain === patternSubdomain;
|
||
|
}
|
||
|
|
||
|
// More than one wildcard is always wrong.
|
||
|
if (patternSubdomainParts.length > 2) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// *.tld wildcards are not allowed.
|
||
|
if (patternParts.length <= 2) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
const [prefix, suffix] = patternSubdomainParts;
|
||
|
|
||
|
if (prefix.length + suffix.length > hostSubdomain.length) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (!hostSubdomain.startsWith(prefix)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (!hostSubdomain.endsWith(suffix)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
module.exports = {
|
||
|
checkServerIdentity,
|
||
|
init
|
||
|
};
|