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
24 changes: 21 additions & 3 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 @@ -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 Down Expand Up @@ -281,6 +284,7 @@ export class CloudSQLInstance {
}
if (!host) {
host = selectIpAddress(metadata.ipAddresses, this.ipType);
host = getFallbackIp(host, 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,16 @@ export class CloudSQLInstance {
});
}
}

function getFallbackIp(currentIp: string, ipAddresses: IpAddresses): string {
if (net.isIP(currentIp) !== 0) {
return currentIp;
}
if (ipAddresses.private) {
return ipAddresses.private;
}
if (ipAddresses.public) {
return ipAddresses.public;
}
return currentIp;
}
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]);
});
});
}
118 changes: 99 additions & 19 deletions src/parse-instance-connection-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
}

export function isSameInstance(
a: InstanceConnectionInfo,
Expand All @@ -30,7 +34,8 @@ export function isSameInstance(

export async function resolveInstanceName(
instanceConnectionName?: string,
domainName?: string
domainName?: string,
client?: Fetcher
): Promise<InstanceConnectionInfo> {
if (!instanceConnectionName && !domainName) {
throw new CloudSQLConnectorError({
Expand All @@ -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:
Expand All @@ -57,11 +62,12 @@ export async function resolveInstanceName(
const connectionNameRegex =
/^(?<projectId>[^:]+(:[^:]+)?):(?<regionId>[^:]+):(?<instanceId>[^:]+)$/;

// 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);
Expand All @@ -73,23 +79,97 @@ export function isInstanceConnectionName(name: string): boolean {
}

export async function resolveDomainName(
name: string
name: string,
client?: Fetcher
): Promise<InstanceConnectionInfo> {
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<string>([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(
Expand Down
31 changes: 31 additions & 0 deletions src/sqladmin-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,16 @@ export interface SQLAdminFetcherOptions {
export class SQLAdminFetcher {
private readonly client: sqladmin_v1beta4.Sqladmin;
private readonly auth: GoogleAuth<AuthClient>;
private readonly sqlAdminAPIEndpoint: string;

constructor({
loginAuth,
sqlAdminAPIEndpoint,
universeDomain,
userAgent,
}: SQLAdminFetcherOptions = {}) {
this.sqlAdminAPIEndpoint =
sqlAdminAPIEndpoint || 'https://sqladmin.googleapis.com';
let auth: GoogleAuth<AuthClient>;

if (loginAuth instanceof GoogleAuth) {
Expand Down Expand Up @@ -321,4 +324,32 @@ export class SQLAdminFetcher {
expirationTime: nearestExpiration,
};
}

async resolveConnectSettings(
dnsName: string,
location: string
): Promise<string> {
setupGaxiosConfig();

const url = `${this.sqlAdminAPIEndpoint}/sql/v1beta4/dns/${dnsName}/locations/${location}:resolveConnectSettings`;

const res =
await this.auth.request<sqladmin_v1beta4.Schema$ConnectSettings>({
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;
}
}
51 changes: 51 additions & 0 deletions test/cloud-sql-instance-dns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,55 @@ t.test('CloudSQLInstance DNS Lookup', async t => {

t.equal(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.equal(
instance.host,
'10.0.0.2',
'Host should fallback to private IP from metadata'
);
}
);
});
Loading
Loading