diff --git a/package-lock.json b/package-lock.json index 57be3fdb..cfa5d6d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,15 @@ "node": ">=22" } }, + "../build/nodejs-sqladmin-grpc": { + "name": "google-cloud-sqladmin-grpc", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@grpc/grpc-js": "^1.9.0", + "@grpc/proto-loader": "^0.7.0" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", diff --git a/src/cloud-sql-instance.ts b/src/cloud-sql-instance.ts index 3dc2e329..6c527149 100644 --- a/src/cloud-sql-instance.ts +++ b/src/cloud-sql-instance.ts @@ -12,7 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {IpAddressTypes, selectIpAddress} from './ip-addresses'; +import net from 'node:net'; +import {IpAddressTypes, selectIpAddress, IpAddresses} from './ip-addresses'; import {InstanceConnectionInfo} from './instance-connection-info'; import { isSameInstance, @@ -47,6 +48,7 @@ interface Fetcher { publicKey: string, authType: AuthTypes ): Promise; + resolveConnectSettings(dnsName: string, location: string): Promise; } interface CloudSQLInstanceOptions { @@ -61,7 +63,7 @@ interface CloudSQLInstanceOptions { interface RefreshResult { ephemeralCert: SslCert; - host: string; + host: string | string[]; privateKey: string; serverCaCert: SslCert; } @@ -72,7 +74,8 @@ export class CloudSQLInstance { ): Promise { const instanceInfo = await resolveInstanceName( options.instanceConnectionName, - options.domainName + options.domainName, + options.sqlAdminFetcher ); const instance = new CloudSQLInstance({ options: options, @@ -99,7 +102,7 @@ export class CloudSQLInstance { public readonly instanceInfo: InstanceConnectionInfo; public ephemeralCert?: SslCert; - public host?: string; + public host?: string | string[]; public port = 3307; public privateKey?: string; public serverCaCert?: SslCert; @@ -268,19 +271,20 @@ export class CloudSQLInstance { rsaKeys.publicKey, this.authType ); - let host; + let host: string[] | undefined; if (this.instanceInfo && this.instanceInfo.domainName) { try { const ips = await resolveARecord(this.instanceInfo.domainName); if (ips && ips.length > 0) { - host = ips[0]; + host = ips; } } catch (e) { // ignore error, fallback to metadata IP } } if (!host) { - host = selectIpAddress(metadata.ipAddresses, this.ipType); + const selectedIps = selectIpAddress(metadata.ipAddresses, this.ipType); + host = getFallbackIps(selectedIps, metadata.ipAddresses); } const privateKey = rsaKeys.privateKey; const serverCaCert = metadata.serverCaCert; @@ -385,7 +389,8 @@ export class CloudSQLInstance { const newInfo = await resolveInstanceName( undefined, - this.instanceInfo.domainName + this.instanceInfo.domainName, + this.sqlAdminFetcher ); if (!isSameInstance(this.instanceInfo, newInfo)) { // Domain name changed. Close and remove, then create a new map entry. @@ -406,3 +411,19 @@ export class CloudSQLInstance { }); } } + +function getFallbackIps( + currentIps: string[], + ipAddresses: IpAddresses +): string[] { + if (currentIps.length > 0 && net.isIP(currentIps[0]) !== 0) { + return currentIps; + } + if (ipAddresses.private && ipAddresses.private.length > 0) { + return ipAddresses.private; + } + if (ipAddresses.public && ipAddresses.public.length > 0) { + return ipAddresses.public; + } + return currentIps; +} diff --git a/src/connector.ts b/src/connector.ts index 111ad141..7e3e418e 100644 --- a/src/connector.ts +++ b/src/connector.ts @@ -18,6 +18,7 @@ import {promisify} from 'node:util'; import {AuthClient, GoogleAuth} from 'google-auth-library'; import {CloudSQLInstance} from './cloud-sql-instance'; import {getSocket} from './socket'; +import {FailoverSocket} from './failover-socket'; import {IpAddressTypes} from './ip-addresses'; import {AuthTypes} from './auth-types'; import {SQLAdminFetcher} from './sqladmin-fetcher'; @@ -243,15 +244,24 @@ export class Connector { privateKey && serverCaCert ) { + let socket; + const hosts = Array.isArray(host) ? host : [host]; + if (hosts.length > 1) { + const failoverSocket = new FailoverSocket(hosts, port); + failoverSocket.startConnect(); + socket = failoverSocket; + } + const tlsSocket = getSocket({ instanceInfo, ephemeralCert, - host, + host: hosts[0], port, privateKey, serverCaCert, instanceDnsName: dnsName, serverName: instanceInfo.domainName || dnsName, // use the configured domain name, or the instance dnsName. + socket: socket, }); tlsSocket.once('error', () => { cloudSqlInstance.forceRefresh(); diff --git a/src/dns-lookup.ts b/src/dns-lookup.ts index fe5060f8..a1db33ac 100644 --- a/src/dns-lookup.ts +++ b/src/dns-lookup.ts @@ -60,3 +60,30 @@ export async function resolveARecord(name: string): Promise { }); }); } + +export async function resolveCnameRecord(name: string): Promise { + return new Promise((resolve, reject) => { + dns.resolveCname(name, (err, addresses) => { + if (err) { + reject( + new CloudSQLConnectorError({ + code: 'EDOMAINNAMELOOKUPERROR', + message: 'Error looking up CNAME record for domain ' + name, + errors: [err], + }) + ); + return; + } + if (!addresses || addresses.length === 0) { + reject( + new CloudSQLConnectorError({ + code: 'EDOMAINNAMELOOKUPFAILED', + message: 'No CNAME records returned for domain ' + name, + }) + ); + return; + } + resolve(addresses[0]); + }); + }); +} diff --git a/src/failover-socket.ts b/src/failover-socket.ts new file mode 100644 index 00000000..ed280d80 --- /dev/null +++ b/src/failover-socket.ts @@ -0,0 +1,143 @@ +// Copyright 2026 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 * as net from 'net'; + +export class FailoverSocket extends net.Socket { + private hosts: string[]; + private port: number; + private currentHostIndex = 0; + public actualSocket?: net.Socket; + private writeBuffer: Array<{ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + chunk: any; + encoding: BufferEncoding; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + callback: (err?: Error | null) => void; + }> = []; + + constructor(hosts: string[], port: number) { + super(); + this.hosts = hosts; + this.port = port; + } + + startConnect() { + this.connectNext(); + } + + private connectNext() { + if (this.currentHostIndex >= this.hosts.length) { + this.emit('error', new Error('Failed to connect to any target')); + return; + } + const host = this.hosts[this.currentHostIndex++]; + const socket = new net.Socket(); + + socket.once('error', () => { + socket.removeAllListeners(); + socket.destroy(); + this.connectNext(); + }); + + socket.once('connect', () => { + socket.removeAllListeners('error'); + this.setSocket(socket); + this.emit('connect'); + }); + + socket.connect(this.port, host); + } + + private setSocket(socket: net.Socket) { + this.actualSocket = socket; + + socket.on('data', chunk => { + this.push(chunk); + }); + + socket.on('end', () => { + this.push(null); + }); + + socket.on('error', err => { + this.emit('error', err); + }); + + socket.on('close', hadError => { + this.emit('close', hadError); + }); + + // Flush buffer + while (this.writeBuffer.length > 0) { + const {chunk, encoding, callback} = this.writeBuffer.shift()!; + socket.write(chunk, encoding, callback); + } + } + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + override _write( + chunk: any, // eslint-disable-line @typescript-eslint/no-explicit-any + encoding: BufferEncoding, + callback: (err?: Error | null) => void + ): void { + if (this.actualSocket) { + this.actualSocket.write(chunk, encoding, callback); + } else { + this.writeBuffer.push({chunk, encoding, callback}); + } + } + + override _read(): void { + // Reading is handled by forwarding 'data' event + } + + override setKeepAlive(enable?: boolean, initialDelay?: number): this { + if (this.actualSocket) { + this.actualSocket.setKeepAlive(enable, initialDelay); + } + return this; + } + + override setNoDelay(noDelay?: boolean): this { + if (this.actualSocket) { + this.actualSocket.setNoDelay(noDelay); + } + return this; + } + + override end(cb?: () => void): this; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + override end(chunk: any, cb?: () => void): this; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + override end(chunk: any, encoding: BufferEncoding, cb?: () => void): this; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + override end(chunk?: any, encoding?: any, cb?: any): this { + if (this.actualSocket) { + this.actualSocket.end(chunk, encoding, cb); + } else { + this.destroy(); + if (cb) cb(); + } + return this; + } + + override destroy(err?: Error): this { + if (this.actualSocket) { + this.actualSocket.destroy(err); + } + super.destroy(err); + return this; + } +} diff --git a/src/ip-addresses.ts b/src/ip-addresses.ts index 973e0d14..36da07a5 100644 --- a/src/ip-addresses.ts +++ b/src/ip-addresses.ts @@ -21,13 +21,13 @@ export enum IpAddressTypes { } export declare interface IpAddresses { - public?: string; - private?: string; - psc?: string; + public?: string[]; + private?: string[]; + psc?: string[]; } -const getPublicIpAddress = (ipAddresses: IpAddresses) => { - if (!ipAddresses.public) { +const getPublicIpAddresses = (ipAddresses: IpAddresses): string[] => { + if (!ipAddresses.public || ipAddresses.public.length === 0) { throw new CloudSQLConnectorError({ message: 'Cannot connect to instance, public Ip address not found', code: 'ENOPUBLICSQLADMINIPADDRESS', @@ -36,8 +36,8 @@ const getPublicIpAddress = (ipAddresses: IpAddresses) => { return ipAddresses.public; }; -const getPrivateIpAddress = (ipAddresses: IpAddresses) => { - if (!ipAddresses.private) { +const getPrivateIpAddresses = (ipAddresses: IpAddresses): string[] => { + if (!ipAddresses.private || ipAddresses.private.length === 0) { throw new CloudSQLConnectorError({ message: 'Cannot connect to instance, private Ip address not found', code: 'ENOPRIVATESQLADMINIPADDRESS', @@ -46,8 +46,8 @@ const getPrivateIpAddress = (ipAddresses: IpAddresses) => { return ipAddresses.private; }; -const getPSCIpAddress = (ipAddresses: IpAddresses) => { - if (!ipAddresses.psc) { +const getPSCIpAddresses = (ipAddresses: IpAddresses): string[] => { + if (!ipAddresses.psc || ipAddresses.psc.length === 0) { throw new CloudSQLConnectorError({ message: 'Cannot connect to instance, PSC address not found', code: 'ENOPSCSQLADMINIPADDRESS', @@ -59,14 +59,14 @@ const getPSCIpAddress = (ipAddresses: IpAddresses) => { export function selectIpAddress( ipAddresses: IpAddresses, type: IpAddressTypes | unknown -): string { +): string[] { switch (type) { case IpAddressTypes.PUBLIC: - return getPublicIpAddress(ipAddresses); + return getPublicIpAddresses(ipAddresses); case IpAddressTypes.PRIVATE: - return getPrivateIpAddress(ipAddresses); + return getPrivateIpAddresses(ipAddresses); case IpAddressTypes.PSC: - return getPSCIpAddress(ipAddresses); + return getPSCIpAddresses(ipAddresses); default: throw new CloudSQLConnectorError({ message: 'Cannot connect to instance, it has no supported IP addresses', diff --git a/src/parse-instance-connection-name.ts b/src/parse-instance-connection-name.ts index a0f3b820..3cf4ff11 100644 --- a/src/parse-instance-connection-name.ts +++ b/src/parse-instance-connection-name.ts @@ -14,7 +14,11 @@ import {InstanceConnectionInfo} from './instance-connection-info'; import {CloudSQLConnectorError} from './errors'; -import {resolveTxtRecord} from './dns-lookup'; +import {resolveTxtRecord, resolveCnameRecord} from './dns-lookup'; + +export interface Fetcher { + resolveConnectSettings(dnsName: string, location: string): Promise; +} export function isSameInstance( a: InstanceConnectionInfo, @@ -30,7 +34,8 @@ export function isSameInstance( export async function resolveInstanceName( instanceConnectionName?: string, - domainName?: string + domainName?: string, + client?: Fetcher ): Promise { if (!instanceConnectionName && !domainName) { throw new CloudSQLConnectorError({ @@ -44,7 +49,7 @@ export async function resolveInstanceName( ) { return parseInstanceConnectionName(instanceConnectionName); } else if (domainName && isValidDomainName(domainName)) { - return await resolveDomainName(domainName); + return await resolveDomainName(domainName, client); } else { throw new CloudSQLConnectorError({ message: @@ -57,11 +62,12 @@ export async function resolveInstanceName( const connectionNameRegex = /^(?[^:]+(:[^:]+)?):(?[^:]+):(?[^:]+)$/; -// The domain name pattern in accordance with RFC 1035, RFC 1123 and RFC 2181. -// From Go Connector: const domainNameRegex = /^(?:[_a-z0-9](?:[_a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?)?$/; +const pscDnsRegex = + /^([a-f0-9]{12})\.([^.]+)\.([a-z0-9]+-[a-z0-9]+)\.(sql|sql-psa|sql-psc)\.goog\.?$/; + export function isValidDomainName(name: string): boolean { const matches = String(name).match(domainNameRegex); return Boolean(matches); @@ -73,23 +79,97 @@ export function isInstanceConnectionName(name: string): boolean { } export async function resolveDomainName( - name: string + name: string, + client?: Fetcher ): Promise { - const icn = await resolveTxtRecord(name); - if (!isInstanceConnectionName(icn)) { - throw new CloudSQLConnectorError({ - message: - 'Malformed instance connection name returned for domain ' + - name + - ' : ' + - icn, - code: 'EBADDOMAINCONNECTIONNAME', - }); + let current = name; + const visited = new Set([current]); + + for (let i = 0; i < 10; i++) { + if (isInstanceConnectionName(current)) { + const info = parseInstanceConnectionName(current); + info.domainName = current !== name ? name : undefined; + return info; + } + + const dnsNormalized = current.endsWith('.') + ? current.slice(0, -1) + : current; + const match = dnsNormalized.toLowerCase().match(pscDnsRegex); + if (match) { + const region = match[3]; + if (!client) { + throw new CloudSQLConnectorError({ + message: 'SQLAdmin client is not configured in the resolver.', + code: 'ENOSQLADMINCLIENTCONFIG', + }); + } + + const dnsNameWithDot = dnsNormalized + '.'; + const resolvedConnName = await client.resolveConnectSettings( + dnsNameWithDot, + region + ); + const info = parseInstanceConnectionName(resolvedConnName); + info.domainName = name; + return info; + } + + if (!isValidDomainName(current)) { + throw new CloudSQLConnectorError({ + message: `Malformed domain name: ${current}`, + code: 'EBADDOMAINNAME', + }); + } + + let cnameFound = false; + let cname = ''; + try { + cname = await resolveCnameRecord(current); + cnameFound = true; + } catch (err) { + // No CNAME found + } + + if (cnameFound) { + if (visited.has(cname)) { + throw new CloudSQLConnectorError({ + message: `CNAME loop detected for domain: ${name}`, + code: 'ECNAMELOOPDETECTED', + }); + } + visited.add(cname); + current = cname; + continue; + } + + let txtRecord = ''; + try { + txtRecord = await resolveTxtRecord(current); + } catch (err) { + throw new CloudSQLConnectorError({ + message: `Unable to resolve TXT record for domain ${name}`, + code: 'EDOMAINNAMELOOKUPERROR', + errors: [err as Error], + }); + } + + if (!isInstanceConnectionName(txtRecord)) { + throw new CloudSQLConnectorError({ + message: `Malformed instance connection name returned for domain ${current} : ${txtRecord}`, + code: 'EBADDOMAINCONNECTIONNAME', + }); + } + + const info = parseInstanceConnectionName(txtRecord); + info.domainName = name; + return info; } - const info = parseInstanceConnectionName(icn); - info.domainName = name; - return info; + throw new CloudSQLConnectorError({ + message: `CNAME loop detected or max resolution depth reached for domain: ${name}`, + code: 'ECNAMELOOPDETECTED', + }); } export function parseInstanceConnectionName( diff --git a/src/socket.ts b/src/socket.ts index f52219eb..a248af8c 100644 --- a/src/socket.ts +++ b/src/socket.ts @@ -13,6 +13,7 @@ // limitations under the License. import tls from 'node:tls'; +import net from 'node:net'; import {InstanceConnectionInfo} from './instance-connection-info'; import {SslCert} from './ssl-cert'; import {CloudSQLConnectorError} from './errors'; @@ -28,6 +29,7 @@ interface SocketOptions { serverCaCert: SslCert; instanceDnsName: string; serverName: string; + socket?: net.Socket; } /** @@ -125,10 +127,12 @@ export function getSocket({ serverCaCert, instanceDnsName, serverName, + socket, }: SocketOptions): tls.TLSSocket { const socketOpts = { - host, - port, + socket: socket || undefined, + host: socket ? undefined : host, + port: socket ? undefined : port, secureContext: tls.createSecureContext({ ca: serverCaCert.cert, cert: ephemeralCert.cert, diff --git a/src/sqladmin-fetcher.ts b/src/sqladmin-fetcher.ts index 27dc8d0e..a3443b78 100644 --- a/src/sqladmin-fetcher.ts +++ b/src/sqladmin-fetcher.ts @@ -88,6 +88,7 @@ export interface SQLAdminFetcherOptions { export class SQLAdminFetcher { private readonly client: sqladmin_v1beta4.Sqladmin; private readonly auth: GoogleAuth; + private readonly sqlAdminAPIEndpoint: string; constructor({ loginAuth, @@ -95,6 +96,8 @@ export class SQLAdminFetcher { universeDomain, userAgent, }: SQLAdminFetcherOptions = {}) { + this.sqlAdminAPIEndpoint = + sqlAdminAPIEndpoint || 'https://sqladmin.googleapis.com'; let auth: GoogleAuth; if (loginAuth instanceof GoogleAuth) { @@ -139,10 +142,10 @@ export class SQLAdminFetcher { if (ipResponse) { for (const ip of ipResponse) { if (ip.type === 'PRIMARY' && ip.ipAddress) { - ipAddresses.public = ip.ipAddress; + ipAddresses.public = [ip.ipAddress]; } if (ip.type === 'PRIVATE' && ip.ipAddress) { - ipAddresses.private = ip.ipAddress; + ipAddresses.private = [ip.ipAddress]; } } } @@ -151,6 +154,7 @@ export class SQLAdminFetcher { // Note that we have to check for PSC enablement because CAS instances // also set the dnsName field. + const pscDnsNames: string[] = []; // Search the dns_names field for the PSC DNS Name. if (dnsNames) { for (const dnm of dnsNames) { @@ -159,15 +163,25 @@ export class SQLAdminFetcher { dnm.connectionType === 'PRIVATE_SERVICE_CONNECT' && dnm.dnsScope === 'INSTANCE' ) { - ipAddresses.psc = dnm.name; - break; + pscDnsNames.push(dnm.name.replace(/\.$/, '')); } } } // If the psc dns name was not found, use the legacy dns_name field - if (!ipAddresses.psc && dnsName && pscEnabled) { - ipAddresses.psc = dnsName; + if (pscDnsNames.length === 0 && dnsName && pscEnabled) { + pscDnsNames.push(dnsName.replace(/\.$/, '')); + } + + if (pscDnsNames.length > 0) { + pscDnsNames.sort((a, b) => { + const aIsPsc = a.endsWith('.sql-psc.goog'); + const bIsPsc = b.endsWith('.sql-psc.goog'); + if (aIsPsc && !bIsPsc) return -1; + if (!aIsPsc && bIsPsc) return 1; + return 0; + }); + ipAddresses.psc = pscDnsNames; } if (!ipAddresses.public && !ipAddresses.private && !ipAddresses.psc) { @@ -321,4 +335,32 @@ export class SQLAdminFetcher { expirationTime: nearestExpiration, }; } + + async resolveConnectSettings( + dnsName: string, + location: string + ): Promise { + setupGaxiosConfig(); + + const url = `${this.sqlAdminAPIEndpoint}/sql/v1beta4/dns/${dnsName}/locations/${location}:resolveConnectSettings`; + + const res = + await this.auth.request({ + url, + method: 'GET', + }); + + cleanGaxiosConfig(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = res.data as any; + if (!data || !data.connectionName) { + throw new CloudSQLConnectorError({ + message: `Failed to resolve DNS name: ${dnsName} on location: ${location}.`, + code: 'ENOSQLADMINRESOLVE', + }); + } + + return data.connectionName; + } } diff --git a/test/cloud-sql-instance-dns.ts b/test/cloud-sql-instance-dns.ts index 30ff05ba..c30514ec 100644 --- a/test/cloud-sql-instance-dns.ts +++ b/test/cloud-sql-instance-dns.ts @@ -25,7 +25,7 @@ t.test('CloudSQLInstance DNS Lookup', async t => { async getInstanceMetadata() { return { ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -89,7 +89,7 @@ t.test('CloudSQLInstance DNS Lookup', async t => { }); t.after(() => instance.close()); - t.equal(instance.host, expectedIp, 'Host should match resolved IP'); + t.same(instance.host, [expectedIp], 'Host should match resolved IP'); }); t.test('should fallback to metadata IP when resolution fails', async t => { @@ -110,7 +110,7 @@ t.test('CloudSQLInstance DNS Lookup', async t => { }); t.after(() => instance.close()); - t.equal(instance.host, '127.0.0.1', 'Host should fallback to metadata IP'); + t.same(instance.host, ['127.0.0.1'], 'Host should fallback to metadata IP'); }); t.test( @@ -133,9 +133,9 @@ t.test('CloudSQLInstance DNS Lookup', async t => { }); t.after(() => instance.close()); - t.equal( + t.same( instance.host, - '127.0.0.1', + ['127.0.0.1'], 'Host should fallback to metadata IP' ); } @@ -156,6 +156,57 @@ t.test('CloudSQLInstance DNS Lookup', async t => { }); t.after(() => instance.close()); - t.equal(instance.host, '127.0.0.1', 'Host should use metadata IP'); + t.same(instance.host, ['127.0.0.1'], 'Host should use metadata IP'); }); + + t.test( + 'should fallback to PRIVATE metadata IP when preferred IP is DNS and resolution fails', + async t => { + const dnsName = '1ad3b5d73f10.3oxon2yfo9tob.us-east1.sql.goog'; + const pscFetcher = { + async getInstanceMetadata() { + return { + ipAddresses: { + psc: [dnsName], + private: ['10.0.0.2'], + }, + serverCaCert: { + cert: CA_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }, + }; + }, + async getEphemeralCertificate() { + return { + cert: CLIENT_CERT, + expirationTime: '2033-01-06T10:00:00.232Z', + }; + }, + }; + + resolveARecordMock = async () => { + throw new Error('DNS Error'); + }; + const expectInstanceName = 'my-project:us-east1:my-instance'; + resolveTXTRecordMock = async (name: string) => { + t.equal(name, 'example.com'); + return [expectInstanceName]; + }; + + const instance = await CloudSQLInstance.getCloudSQLInstance({ + ipType: IpAddressTypes.PSC, + authType: AuthTypes.PASSWORD, + domainName: 'example.com', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sqlAdminFetcher: pscFetcher as any, + }); + t.after(() => instance.close()); + + t.same( + instance.host, + ['10.0.0.2'], + 'Host should fallback to private IP from metadata' + ); + } + ); }); diff --git a/test/cloud-sql-instance.ts b/test/cloud-sql-instance.ts index 9b4c2471..caa35af8 100644 --- a/test/cloud-sql-instance.ts +++ b/test/cloud-sql-instance.ts @@ -25,7 +25,7 @@ t.test('CloudSQLInstance', async t => { async getInstanceMetadata() { return { ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -88,7 +88,7 @@ t.test('CloudSQLInstance', async t => { t.same(instance.privateKey, CLIENT_KEY, 'should have expected privateKey'); - t.same(instance.host, '127.0.0.1', 'should have expected host'); + t.same(instance.host, ['127.0.0.1'], 'should have expected host'); t.same(instance.port, 3307, 'should have expected port'); t.same( @@ -583,7 +583,7 @@ t.test('CloudSQLInstance', async t => { const instanceMetadata = await fetcher.getInstanceMetadata(); const ips = ['127.0.0.1', '127.0.0.2']; const ipAddresses = { - public: ips[metadataCount], + public: [ips[metadataCount]], }; metadataCount++; return { @@ -613,7 +613,7 @@ t.test('CloudSQLInstance', async t => { // isExpirationTimeValid does not work as expected t.strictSame( instance.host, - '127.0.0.1', + ['127.0.0.1'], 'should not have updated values' ); instance.cancelRefresh(); diff --git a/test/connector.ts b/test/connector.ts index 19f23e49..51046c3d 100644 --- a/test/connector.ts +++ b/test/connector.ts @@ -32,7 +32,7 @@ t.test('Connector', async t => { getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -82,7 +82,7 @@ t.test('Connector missing instance info error', async t => { getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -136,7 +136,7 @@ t.test('Connector bad instance info error', async t => { getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -192,7 +192,7 @@ t.test('start only a single instance info per connection name', async t => { getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -254,7 +254,7 @@ t.test( getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -315,7 +315,7 @@ t.test('Connector, supporting Tedious driver', async t => { getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -407,7 +407,7 @@ t.test('Connector force refresh on socket connection error', async t => { getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, @@ -529,7 +529,7 @@ function setupConnectorModule(t) { getInstanceMetadata() { return Promise.resolve({ ipAddresses: { - public: '127.0.0.1', + public: ['127.0.0.1'], }, serverCaCert: { cert: CA_CERT, diff --git a/test/dns-lookup.ts b/test/dns-lookup.ts index 76090ab1..892788ab 100644 --- a/test/dns-lookup.ts +++ b/test/dns-lookup.ts @@ -15,7 +15,7 @@ import t from 'tap'; t.test('lookup dns with mock responses', async t => { - const {resolveTxtRecord, resolveARecord} = t.mockRequire( + const {resolveTxtRecord, resolveARecord, resolveCnameRecord} = t.mockRequire( '../src/dns-lookup.ts', { 'node:dns': { @@ -50,6 +50,15 @@ t.test('lookup dns with mock responses', async t => { callback(new Error('not found')); } }, + resolveCname: (name, callback) => { + if (name === 'cname.example.com') { + callback(null, ['target.example.com']); + } else if (name === 'empty.example.com') { + callback(null, []); + } else { + callback(new Error('not found')); + } + }, }, } ); @@ -96,6 +105,23 @@ t.test('lookup dns with mock responses', async t => { /not found/, 'should reject on error' ); + + // resolveCnameRecord tests + t.same( + await resolveCnameRecord('cname.example.com'), + 'target.example.com', + 'should resolve CNAME record' + ); + t.rejects( + async () => await resolveCnameRecord('empty.example.com'), + {code: 'EDOMAINNAMELOOKUPFAILED'}, + 'should throw type error if empty cname results returned' + ); + t.rejects( + async () => await resolveCnameRecord('not-found'), + {code: 'EDOMAINNAMELOOKUPERROR'}, + 'should reject on CNAME lookup error' + ); }); t.test('lookup dns with real responses', async t => { diff --git a/test/failover-socket.ts b/test/failover-socket.ts new file mode 100644 index 00000000..0486de40 --- /dev/null +++ b/test/failover-socket.ts @@ -0,0 +1,78 @@ +// Copyright 2026 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 t from 'tap'; +import * as net from 'node:net'; +import {FailoverSocket} from '../src/failover-socket'; + +t.test('FailoverSocket failover success', async t => { + const port = 3005; + + const server = net.createServer(socket => { + socket.write('hello'); + socket.end(); + }); + + await new Promise(resolve => { + server.listen(port, '127.0.0.1', () => { + resolve(); + }); + }); + + t.teardown(() => { + server.close(); + }); + + // 127.0.0.2 should fail (refused), 127.0.0.1 should succeed + const hosts = ['127.0.0.2', '127.0.0.1']; + const failoverSocket = new FailoverSocket(hosts, port); + failoverSocket.startConnect(); + + await new Promise((resolve, reject) => { + failoverSocket.on('connect', () => { + t.pass('connected successfully'); + failoverSocket.end(); + resolve(); + }); + + failoverSocket.on('error', err => { + reject(err); + }); + }); +}); + +t.test('FailoverSocket all hosts fail', async t => { + const port = 3006; + + const hosts = ['127.0.0.2', '127.0.0.3']; + const failoverSocket = new FailoverSocket(hosts, port); + failoverSocket.startConnect(); + + await new Promise((resolve, reject) => { + failoverSocket.on('connect', () => { + t.fail('should not connect'); + failoverSocket.end(); + reject(new Error('should not connect')); + }); + + failoverSocket.on('error', err => { + t.match( + err.message, + 'Failed to connect to any target', + 'should report failure' + ); + resolve(); + }); + }); +}); diff --git a/test/ip-addresses.ts b/test/ip-addresses.ts index 1f0c782d..c6a81d9d 100644 --- a/test/ip-addresses.ts +++ b/test/ip-addresses.ts @@ -42,35 +42,35 @@ t.throws( t.same( selectIpAddress( { - public: '0.0.0.0', - private: '0.0.0.2', + public: ['0.0.0.0'], + private: ['0.0.0.2'], }, IpAddressTypes.PUBLIC ), - '0.0.0.0', + ['0.0.0.0'], 'should select public ip' ); t.same( selectIpAddress( { - private: '0.0.0.2', + private: ['0.0.0.2'], }, IpAddressTypes.PRIVATE ), - '0.0.0.2', + ['0.0.0.2'], 'should select private ip' ); t.same( selectIpAddress( { - public: '0.0.0.0', - private: '0.0.0.2', - psc: 'abcde.12345.us-central1.sql.goog', + public: ['0.0.0.0'], + private: ['0.0.0.2'], + psc: ['abcde.12345.us-central1.sql.goog'], }, IpAddressTypes.PSC ), - 'abcde.12345.us-central1.sql.goog', + ['abcde.12345.us-central1.sql.goog'], 'should select psc ip' ); diff --git a/test/parse-instance-connection-name.ts b/test/parse-instance-connection-name.ts index d3093103..23b0d458 100644 --- a/test/parse-instance-connection-name.ts +++ b/test/parse-instance-connection-name.ts @@ -369,3 +369,162 @@ t.test('isSameInstance', async t => { ); } }); + +t.test('resolveDomainName PSC DNS Mock', async t => { + const mockClient = { + resolveConnectSettings: async (dnsName: string, location: string) => { + t.same(dnsName, '0123456789ab.fedcba9876543.europe-north2.sql-psc.goog.'); + t.same(location, 'europe-north2'); + return 'my-project:europe-north2:my-instance'; + }, + }; + + const {resolveDomainName} = t.mockRequire( + '../src/parse-instance-connection-name', + { + '../src/dns-lookup': { + resolveCnameRecord: async () => { + throw new Error('No CNAME'); + }, + resolveTxtRecord: async () => { + throw new Error('No TXT'); + }, + }, + } + ); + + t.same( + await resolveDomainName( + '0123456789ab.fedcba9876543.europe-north2.sql-psc.goog', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockClient as any + ), + { + projectId: 'my-project', + regionId: 'europe-north2', + instanceId: 'my-instance', + domainName: '0123456789ab.fedcba9876543.europe-north2.sql-psc.goog', + }, + 'should resolve direct PSC DNS' + ); +}); + +t.test('resolveDomainName CNAME to PSC DNS Mock', async t => { + const cnameTarget = '0123456789ab.fedcba9876543.europe-north2.sql-psc.goog'; + const mockClient = { + resolveConnectSettings: async (dnsName: string, location: string) => { + t.same(dnsName, cnameTarget + '.'); + t.same(location, 'europe-north2'); + return 'my-project:europe-north2:my-instance'; + }, + }; + + const {resolveDomainName} = t.mockRequire( + '../src/parse-instance-connection-name', + { + '../src/dns-lookup': { + resolveCnameRecord: async (name: string) => { + if (name === 'db.example.com') { + return cnameTarget; + } + throw new Error('No CNAME'); + }, + resolveTxtRecord: async () => { + throw new Error('No TXT'); + }, + }, + } + ); + + t.same( + await resolveDomainName( + 'db.example.com', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockClient as any + ), + { + projectId: 'my-project', + regionId: 'europe-north2', + instanceId: 'my-instance', + domainName: 'db.example.com', + }, + 'should resolve CNAME to PSC DNS' + ); +}); + +t.test('resolveDomainName Recursive CNAME to PSC DNS Mock', async t => { + const cname2 = 'name2.example.com'; + const cnameTarget = '0123456789ab.fedcba9876543.europe-north2.sql-psc.goog'; + const mockClient = { + resolveConnectSettings: async (dnsName: string, location: string) => { + t.same(dnsName, cnameTarget + '.'); + t.same(location, 'europe-north2'); + return 'my-project:europe-north2:my-instance'; + }, + }; + + const {resolveDomainName} = t.mockRequire( + '../src/parse-instance-connection-name', + { + '../src/dns-lookup': { + resolveCnameRecord: async (name: string) => { + if (name === 'name1.example.com') { + return cname2; + } + if (name === cname2) { + return cnameTarget; + } + throw new Error('No CNAME'); + }, + resolveTxtRecord: async () => { + throw new Error('No TXT'); + }, + }, + } + ); + + t.same( + await resolveDomainName( + 'name1.example.com', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockClient as any + ), + { + projectId: 'my-project', + regionId: 'europe-north2', + instanceId: 'my-instance', + domainName: 'name1.example.com', + }, + 'should resolve recursive CNAME to PSC DNS' + ); +}); + +t.test('resolveDomainName CNAME loop detection', async t => { + const cname2 = 'name2.example.com'; + + const {resolveDomainName} = t.mockRequire( + '../src/parse-instance-connection-name', + { + '../src/dns-lookup': { + resolveCnameRecord: async (name: string) => { + if (name === 'name1.example.com') { + return cname2; + } + if (name === cname2) { + return 'name1.example.com'; + } + throw new Error('No CNAME'); + }, + resolveTxtRecord: async () => { + throw new Error('No TXT'); + }, + }, + } + ); + + await t.rejects( + async () => await resolveDomainName('name1.example.com'), + {code: 'ECNAMELOOPDETECTED'}, + 'should throw error if CNAME loop is detected' + ); +}); diff --git a/test/sqladmin-fetcher.ts b/test/sqladmin-fetcher.ts index 63c69003..5595450d 100644 --- a/test/sqladmin-fetcher.ts +++ b/test/sqladmin-fetcher.ts @@ -191,9 +191,9 @@ t.test('getInstanceMetadata', async t => { instanceMetadata, { ipAddresses: { - public: '0.0.0.0', - private: '10.0.0.1', - psc: 'abcde.12345.us-central1.sql.goog', + public: ['0.0.0.0'], + private: ['10.0.0.1'], + psc: ['abcde.12345.us-central1.sql.goog'], }, serverCaCert: { cert: '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----', @@ -206,6 +206,47 @@ t.test('getInstanceMetadata', async t => { ); }); +t.test('getInstanceMetadata multiple PSC DNS names sorted', async t => { + const instanceConnectionInfo: InstanceConnectionInfo = { + projectId: 'my-project', + regionId: 'us-east1', + instanceId: 'my-instance', + }; + mockSQLAdminGetInstanceMetadata(instanceConnectionInfo, { + dnsNames: [ + { + name: 'dns1.sql.goog', + connectionType: 'PRIVATE_SERVICE_CONNECT', + dnsScope: 'INSTANCE', + }, + { + name: 'dns2.sql-psc.goog', + connectionType: 'PRIVATE_SERVICE_CONNECT', + dnsScope: 'INSTANCE', + }, + { + name: 'dns3.sql.goog', + connectionType: 'PRIVATE_SERVICE_CONNECT', + dnsScope: 'INSTANCE', + }, + ], + ipAddresses: [], + pscEnabled: true, + }); + + const fetcher = new SQLAdminFetcher(); + const instanceMetadata = await fetcher.getInstanceMetadata( + instanceConnectionInfo + ); + t.same( + instanceMetadata.ipAddresses, + { + psc: ['dns2.sql-psc.goog', 'dns1.sql.goog', 'dns3.sql.goog'], + }, + 'should sort PSC DNS names prioritizing .sql-psc.goog' + ); +}); + t.test('getInstanceMetadata custom SQL Admin API endpoint', async t => { const sqlAdminAPIEndpoint = 'https://sqladmin.mydomain.com'; new SQLAdminFetcher({sqlAdminAPIEndpoint}); @@ -483,3 +524,59 @@ t.test('getEphemeralCertificate sets access token on IAM', async t => { t.same(ephemeralCert.cert, CLIENT_CERT, 'should return expected ssl cert'); }); + +t.test('resolveConnectSettings', async t => { + let requestCalls = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let requestOpts: any = null; + + const {SQLAdminFetcher} = t.mockRequire('../src/sqladmin-fetcher', { + 'google-auth-library': { + GoogleAuth: class { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async request(opts: any) { + requestCalls++; + requestOpts = opts; + if (opts.url.includes('missing')) { + return {data: {}}; + } + return { + data: { + connectionName: 'my-project:my-region:my-instance', + }, + }; + } + }, + }, + '@googleapis/sqladmin': { + sqladmin_v1beta4: {Sqladmin}, + }, + }); + + await t.test('should successfully resolve CNAME DNS name', async t => { + const fetcher = new SQLAdminFetcher(); + const connectionName = await fetcher.resolveConnectSettings( + 'my-dns', + 'my-region' + ); + t.same(connectionName, 'my-project:my-region:my-instance'); + t.same(requestCalls, 1); + t.same( + requestOpts.url, + 'https://sqladmin.googleapis.com/sql/v1beta4/dns/my-dns/locations/my-region:resolveConnectSettings' + ); + t.same(requestOpts.method, 'GET'); + }); + + await t.test( + 'should throw error if connectionName missing in response', + async t => { + const fetcher = new SQLAdminFetcher(); + t.rejects( + fetcher.resolveConnectSettings('missing-dns', 'my-region'), + {code: 'ENOSQLADMINRESOLVE'}, + 'should reject on missing connectionName' + ); + } + ); +});