/* * 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} */ 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} 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} */ 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 };