Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 29 additions & 8 deletions src/cloud-sql-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,6 +48,7 @@ interface Fetcher {
publicKey: string,
authType: AuthTypes
): Promise<SslCert>;
resolveConnectSettings(dnsName: string, location: string): Promise<string>;
}

interface CloudSQLInstanceOptions {
Expand All @@ -61,7 +63,7 @@ interface CloudSQLInstanceOptions {

interface RefreshResult {
ephemeralCert: SslCert;
host: string;
host: string | string[];
privateKey: string;
serverCaCert: SslCert;
}
Expand All @@ -72,7 +74,8 @@ export class CloudSQLInstance {
): Promise<CloudSQLInstance> {
const instanceInfo = await resolveInstanceName(
options.instanceConnectionName,
options.domainName
options.domainName,
options.sqlAdminFetcher
);
const instance = new CloudSQLInstance({
options: options,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}
12 changes: 11 additions & 1 deletion src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
27 changes: 27 additions & 0 deletions src/dns-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,30 @@ export async function resolveARecord(name: string): Promise<string[]> {
});
});
}

export async function resolveCnameRecord(name: string): Promise<string> {
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]);
});
});
}
143 changes: 143 additions & 0 deletions src/failover-socket.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
26 changes: 13 additions & 13 deletions src/ip-addresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
Loading
Loading