UNPKG

@google-cloud/cloud-sql-connector

Version:

A JavaScript library for connecting securely to your Cloud SQL instances

271 lines 11.2 kB
// Copyright 2023 Google LLC // // 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 // // https://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. import { IpAddressTypes, selectIpAddress } from './ip-addresses.js'; import { isSameInstance, resolveInstanceName, } from './parse-instance-connection-name.js'; import { generateKeys } from './crypto.js'; import { getRefreshInterval, isExpirationTimeValid } from './time.js'; import { AuthTypes } from './auth-types.js'; import { CloudSQLConnectorError } from './errors.js'; export class CloudSQLInstance { static async getCloudSQLInstance(options) { const instance = new CloudSQLInstance({ options: options, instanceInfo: await resolveInstanceName(options.instanceConnectionName, options.domainName), }); await instance.refresh(); return instance; } constructor({ options, instanceInfo, }) { this.establishedConnection = false; this.scheduledRefreshID = undefined; this.checkDomainID = undefined; this.closed = false; this.sockets = new Set(); this.port = 3307; this.serverCaMode = ''; this.dnsName = ''; this.instanceInfo = instanceInfo; this.authType = options.authType || AuthTypes.PASSWORD; this.ipType = options.ipType || IpAddressTypes.PUBLIC; this.limitRateInterval = options.limitRateInterval || 30 * 1000; // 30 seconds this.sqlAdminFetcher = options.sqlAdminFetcher; this.failoverPeriod = options.failoverPeriod || 30 * 1000; // 30 seconds } // p-throttle library has to be initialized in an async scope in order to // use dynamic import so that it's also compatible with CommonJS async initializeRateLimiter() { if (this.throttle) { return; } const pThrottle = (await import('p-throttle')).default; this.throttle = pThrottle({ limit: 1, interval: this.limitRateInterval, strict: true, }); } forceRefresh() { // if a refresh is already ongoing, just await for its promise to fulfill // so that a new instance info is available before reconnecting if (this.next) { return new Promise(resolve => { if (this.next) { this.next.finally(resolve); } else { resolve(); } }); } this.cancelRefresh(); this.scheduleRefresh(0); return new Promise(resolve => { // setTimeout() to yield execution to allow other refresh background // task to start. setTimeout(() => { if (this.next) { // If there is a refresh promise in progress, resolve this promise // when the refresh is complete. this.next.finally(resolve); } else { // Else resolve immediately. resolve(); } }, 0); }); } refresh() { var _a; if (this.closed) { this.scheduledRefreshID = undefined; this.next = undefined; return Promise.reject('closed'); } // Lazy instantiation of the checkDomain interval on the first refresh // This avoids issues with test cases that instantiate a CloudSqlInstance. // If failoverPeriod is 0 (or negative) don't check for DNS updates. if (((_a = this === null || this === void 0 ? void 0 : this.instanceInfo) === null || _a === void 0 ? void 0 : _a.domainName) && !this.checkDomainID && this.failoverPeriod > 0) { this.checkDomainID = setInterval(() => { this.checkDomainChanged(); }, this.failoverPeriod); } const currentRefreshId = this.scheduledRefreshID; // Since forceRefresh might be invoked during an ongoing refresh // we keep track of the ongoing promise in order to be able to await // for it in the forceRefresh method. // In case the throttle mechanism is already initialized, we add the // extra wait time `limitRateInterval` in order to limit the rate of // requests to Cloud SQL Admin APIs. this.next = (this.throttle && this.scheduledRefreshID ? this.throttle(this.performRefresh).call(this) : this.performRefresh()) // These needs to be part of the chain of promise referenced in // next in order to avoid race conditions .then((nextValues) => { var _a; // in case the id at the moment of starting this refresh cycle has // changed, that means that it has been canceled if (currentRefreshId !== this.scheduledRefreshID) { return; } // In case the performRefresh method succeeded // then we go ahead and update values this.updateValues(nextValues); const refreshInterval = getRefreshInterval( /* c8 ignore next */ String((_a = this.ephemeralCert) === null || _a === void 0 ? void 0 : _a.expirationTime)); this.scheduleRefresh(refreshInterval); // This is the end of the successful refresh chain, so now // we release the reference to the next this.next = undefined; }) .catch((err) => { // In case there's already an active connection we won't throw // refresh errors to the final user, scheduling a new // immediate refresh instead. if (this.establishedConnection) { if (currentRefreshId === this.scheduledRefreshID) { this.scheduleRefresh(0); } } else { throw err; } // This refresh cycle has failed, releases ref to next this.next = undefined; }) // The rate limiter needs to be initialized _after_ assigning a ref // to next in order to avoid race conditions with // the forceRefresh check that ensures a refresh cycle is not ongoing .then(() => { this.initializeRateLimiter(); }); return this.next; } // The performRefresh method will perform all the necessary async steps // in order to get a new set of values for an instance that can then be // used to create new connections to a Cloud SQL instance. It throws in // case any of the internal steps fails. async performRefresh() { if (this.closed) { // The connector may be closed while the rate limiter delayed // a call to performRefresh() so check this.closed before continuing. return Promise.reject('closed'); } const rsaKeys = await generateKeys(); const metadata = await this.sqlAdminFetcher.getInstanceMetadata(this.instanceInfo); const ephemeralCert = await this.sqlAdminFetcher.getEphemeralCertificate(this.instanceInfo, rsaKeys.publicKey, this.authType); const host = selectIpAddress(metadata.ipAddresses, this.ipType); const privateKey = rsaKeys.privateKey; const serverCaCert = metadata.serverCaCert; this.serverCaMode = metadata.serverCaMode; this.dnsName = metadata.dnsName; const currentValues = { ephemeralCert: this.ephemeralCert, host: this.host, privateKey: this.privateKey, serverCaCert: this.serverCaCert, }; const nextValues = { ephemeralCert, host, privateKey, serverCaCert, }; // In the rather odd case that the current ephemeral certificate is still // valid while we get an invalid result from the API calls, then preserve // the current metadata. if (this.isValid(currentValues) && !this.isValid(nextValues)) { return currentValues; } return nextValues; } isValid({ ephemeralCert, host, privateKey, serverCaCert, }) { if (!ephemeralCert || !host || !privateKey || !serverCaCert) { return false; } return isExpirationTimeValid(ephemeralCert.expirationTime); } updateValues(nextValues) { const { ephemeralCert, host, privateKey, serverCaCert } = nextValues; this.ephemeralCert = ephemeralCert; this.host = host; this.privateKey = privateKey; this.serverCaCert = serverCaCert; } scheduleRefresh(delay) { if (this.closed) { return; } this.scheduledRefreshID = setTimeout(() => this.refresh(), delay); } cancelRefresh() { // If refresh has not yet started, then cancel the setTimeout if (this.scheduledRefreshID) { clearTimeout(this.scheduledRefreshID); } this.scheduledRefreshID = null; } // Mark this instance as having an active connection. This is important to // ensure any possible errors thrown during a future refresh cycle should // not be thrown to the final user. setEstablishedConnection() { this.establishedConnection = true; } // close stops any refresh process in progress and prevents future refresh // connections. close() { this.closed = true; this.cancelRefresh(); if (this.checkDomainID) { clearInterval(this.checkDomainID); this.checkDomainID = null; } for (const socket of this.sockets) { socket.destroy(new CloudSQLConnectorError({ code: 'ERRCLOSED', message: 'The connector was closed.', })); } } isClosed() { return this.closed; } async checkDomainChanged() { if (!this.instanceInfo.domainName) { return; } const newInfo = await resolveInstanceName(undefined, this.instanceInfo.domainName); if (!isSameInstance(this.instanceInfo, newInfo)) { // Domain name changed. Close and remove, then create a new map entry. this.close(); } } addSocket(socket) { if (!this.instanceInfo.domainName) { // This was not connected by domain name. Ignore all sockets. return; } // Add the socket to the list this.sockets.add(socket); // When the socket is closed, remove it. socket.once('closed', () => { this.sockets.delete(socket); }); } } //# sourceMappingURL=cloud-sql-instance.js.map