Skip to content

Commit 7655fd8

Browse files
authored
feat: Use configured DNS name to lookup instance IP address (#514)
When a custom DNS name is used to connect to a Cloud SQL instance, the dialer should first attempt to resolve the custom DNS name to an IP address and use that for the connection. If the lookup fails, the dialer should fall back to using the IP address from the instance metadata. Fixes #513
1 parent b67dc7b commit 7655fd8

File tree

4 files changed

+246
-29
lines changed

4 files changed

+246
-29
lines changed

src/cloud-sql-instance.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
isSameInstance,
1919
resolveInstanceName,
2020
} from './parse-instance-connection-name';
21+
import {resolveARecord} from './dns-lookup';
2122
import {InstanceMetadata} from './sqladmin-fetcher';
2223
import {generateKeys} from './crypto';
2324
import {RSAKeys} from './rsa-keys';
@@ -69,12 +70,13 @@ export class CloudSQLInstance {
6970
static async getCloudSQLInstance(
7071
options: CloudSQLInstanceOptions
7172
): Promise<CloudSQLInstance> {
73+
const instanceInfo = await resolveInstanceName(
74+
options.instanceConnectionName,
75+
options.domainName
76+
);
7277
const instance = new CloudSQLInstance({
7378
options: options,
74-
instanceInfo: await resolveInstanceName(
75-
options.instanceConnectionName,
76-
options.domainName
77-
),
79+
instanceInfo,
7880
});
7981
await instance.refresh();
8082
return instance;
@@ -266,7 +268,20 @@ export class CloudSQLInstance {
266268
rsaKeys.publicKey,
267269
this.authType
268270
);
269-
const host = selectIpAddress(metadata.ipAddresses, this.ipType);
271+
let host;
272+
if (this.instanceInfo && this.instanceInfo.domainName) {
273+
try {
274+
const ips = await resolveARecord(this.instanceInfo.domainName);
275+
if (ips && ips.length > 0) {
276+
host = ips[0];
277+
}
278+
} catch (e) {
279+
// ignore error, fallback to metadata IP
280+
}
281+
}
282+
if (!host) {
283+
host = selectIpAddress(metadata.ipAddresses, this.ipType);
284+
}
270285
const privateKey = rsaKeys.privateKey;
271286
const serverCaCert = metadata.serverCaCert;
272287
this.serverCaMode = metadata.serverCaMode;

src/dns-lookup.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,15 @@ export async function resolveTxtRecord(name: string): Promise<string> {
4848
});
4949
});
5050
}
51+
52+
export async function resolveARecord(name: string): Promise<string[]> {
53+
return new Promise((resolve, reject) => {
54+
dns.resolve4(name, (err, addresses) => {
55+
if (err) {
56+
reject(err);
57+
return;
58+
}
59+
resolve(addresses);
60+
});
61+
});
62+
}

test/cloud-sql-instance-dns.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import t from 'tap';
16+
import {IpAddressTypes} from '../src/ip-addresses';
17+
import {AuthTypes} from '../src/auth-types';
18+
import {CA_CERT, CLIENT_CERT, CLIENT_KEY} from './fixtures/certs';
19+
import {setupCredentials} from './fixtures/setup-credentials';
20+
21+
t.test('CloudSQLInstance DNS Lookup', async t => {
22+
setupCredentials(t);
23+
24+
const fetcher = {
25+
async getInstanceMetadata() {
26+
return {
27+
ipAddresses: {
28+
public: '127.0.0.1',
29+
},
30+
serverCaCert: {
31+
cert: CA_CERT,
32+
expirationTime: '2033-01-06T10:00:00.232Z',
33+
},
34+
};
35+
},
36+
async getEphemeralCertificate() {
37+
return {
38+
cert: CLIENT_CERT,
39+
expirationTime: '2033-01-06T10:00:00.232Z',
40+
};
41+
},
42+
};
43+
44+
let resolveARecordMock = async (): Promise<string[]> => {
45+
return [];
46+
};
47+
let resolveTXTRecordMock = async (): Promise<string[]> => {
48+
return [];
49+
};
50+
51+
const {CloudSQLInstance} = t.mockRequire('../src/cloud-sql-instance', {
52+
'../src/crypto': {
53+
generateKeys: async () => ({
54+
publicKey: '-----BEGIN PUBLIC KEY-----',
55+
privateKey: CLIENT_KEY,
56+
}),
57+
},
58+
'../src/time': {
59+
getRefreshInterval() {
60+
return 50;
61+
},
62+
isExpirationTimeValid() {
63+
return true;
64+
},
65+
},
66+
'../src/dns-lookup': {
67+
resolveARecord: async (name: string) => resolveARecordMock(name),
68+
resolveTxtRecord: async (name: string) => resolveTXTRecordMock(name),
69+
},
70+
});
71+
72+
t.test('should use resolved IP when domainName is present', async t => {
73+
const expectedIp = '10.0.0.1';
74+
const expectInstanceName = 'my-project:us-east1:my-instance';
75+
resolveARecordMock = async (name: string) => {
76+
t.equal(name, 'example.com');
77+
return [expectedIp];
78+
};
79+
resolveTXTRecordMock = async (name: string) => {
80+
t.equal(name, 'example.com');
81+
return [expectInstanceName];
82+
};
83+
84+
const instance = await CloudSQLInstance.getCloudSQLInstance({
85+
ipType: IpAddressTypes.PUBLIC,
86+
authType: AuthTypes.PASSWORD,
87+
domainName: 'example.com',
88+
sqlAdminFetcher: fetcher,
89+
});
90+
t.after(() => instance.close());
91+
92+
t.equal(instance.host, expectedIp, 'Host should match resolved IP');
93+
});
94+
95+
t.test('should fallback to metadata IP when resolution fails', async t => {
96+
resolveARecordMock = async () => {
97+
throw new Error('DNS Error');
98+
};
99+
const expectInstanceName = 'my-project:us-east1:my-instance';
100+
resolveTXTRecordMock = async (name: string) => {
101+
t.equal(name, 'example.com');
102+
return [expectInstanceName];
103+
};
104+
105+
const instance = await CloudSQLInstance.getCloudSQLInstance({
106+
ipType: IpAddressTypes.PUBLIC,
107+
authType: AuthTypes.PASSWORD,
108+
domainName: 'example.com',
109+
sqlAdminFetcher: fetcher,
110+
});
111+
t.after(() => instance.close());
112+
113+
t.equal(instance.host, '127.0.0.1', 'Host should fallback to metadata IP');
114+
});
115+
116+
t.test(
117+
'should fallback to metadata IP when resolution returns empty',
118+
async t => {
119+
resolveARecordMock = async () => {
120+
return [];
121+
};
122+
const expectInstanceName = 'my-project:us-east1:my-instance';
123+
resolveTXTRecordMock = async (name: string) => {
124+
t.equal(name, 'example.com');
125+
return [expectInstanceName];
126+
};
127+
128+
const instance = await CloudSQLInstance.getCloudSQLInstance({
129+
ipType: IpAddressTypes.PUBLIC,
130+
authType: AuthTypes.PASSWORD,
131+
domainName: 'example.com',
132+
sqlAdminFetcher: fetcher,
133+
});
134+
t.after(() => instance.close());
135+
136+
t.equal(
137+
instance.host,
138+
'127.0.0.1',
139+
'Host should fallback to metadata IP'
140+
);
141+
}
142+
);
143+
144+
t.test('should use metadata IP when domainName is not present', async t => {
145+
// resolveARecord should not be called, but if it is, ensure it doesn't return the expected IP
146+
resolveARecordMock = async () => {
147+
t.fail('Should not attempt to resolve DNS');
148+
return ['10.0.0.1'];
149+
};
150+
151+
const instance = await CloudSQLInstance.getCloudSQLInstance({
152+
ipType: IpAddressTypes.PUBLIC,
153+
authType: AuthTypes.PASSWORD,
154+
instanceConnectionName: 'my-project:us-east1:my-instance',
155+
sqlAdminFetcher: fetcher,
156+
});
157+
t.after(() => instance.close());
158+
159+
t.equal(instance.host, '127.0.0.1', 'Host should use metadata IP');
160+
});
161+
});

test/dns-lookup.ts

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,44 @@
1515
import t from 'tap';
1616

1717
t.test('lookup dns with mock responses', async t => {
18-
const {resolveTxtRecord} = t.mockRequire('../src/dns-lookup.ts', {
19-
'node:dns': {
20-
resolveTxt: (name, callback) => {
21-
switch (name) {
22-
case 'db.example.com':
23-
callback(null, [['my-project:region-1:instance']]);
24-
return;
25-
case 'multiple.example.com':
26-
callback(null, [
27-
['my-project:region-1:instance'],
28-
['another-project:region-1:instance'],
29-
]);
30-
return;
31-
case 'split.example.com':
32-
callback(null, [['my-project:', 'region-1:instance']]);
33-
return;
34-
case 'empty.example.com':
18+
const {resolveTxtRecord, resolveARecord} = t.mockRequire(
19+
'../src/dns-lookup.ts',
20+
{
21+
'node:dns': {
22+
resolveTxt: (name, callback) => {
23+
switch (name) {
24+
case 'db.example.com':
25+
callback(null, [['my-project:region-1:instance']]);
26+
return;
27+
case 'multiple.example.com':
28+
callback(null, [
29+
['my-project:region-1:instance'],
30+
['another-project:region-1:instance'],
31+
]);
32+
return;
33+
case 'split.example.com':
34+
callback(null, [['my-project:', 'region-1:instance']]);
35+
return;
36+
case 'empty.example.com':
37+
callback(null, []);
38+
return;
39+
default:
40+
callback(new Error('not found'), null);
41+
return;
42+
}
43+
},
44+
resolve4: (name, callback) => {
45+
if (name === 'example.com') {
46+
callback(null, ['10.0.0.1']);
47+
} else if (name === 'empty.example.com') {
3548
callback(null, []);
36-
return;
37-
default:
38-
callback(new Error('not found'), null);
39-
return;
40-
}
49+
} else {
50+
callback(new Error('not found'));
51+
}
52+
},
4153
},
42-
},
43-
});
54+
}
55+
);
4456

4557
t.same(
4658
await resolveTxtRecord('db.example.com'),
@@ -67,6 +79,23 @@ t.test('lookup dns with mock responses', async t => {
6779
{code: 'EDOMAINNAMELOOKUPFAILED'},
6880
'should throw type error if an extra item is provided'
6981
);
82+
83+
// resolveARecord tests
84+
t.same(
85+
await resolveARecord('example.com'),
86+
['10.0.0.1'],
87+
'should resolve A record'
88+
);
89+
t.same(
90+
await resolveARecord('empty.example.com'),
91+
[],
92+
'should return empty array'
93+
);
94+
t.rejects(
95+
async () => await resolveARecord('not-found'),
96+
/not found/,
97+
'should reject on error'
98+
);
7099
});
71100

72101
t.test('lookup dns with real responses', async t => {

0 commit comments

Comments
 (0)