Skip to content

Commit 3282ed3

Browse files
committed
feat: use configured DNS name to lookup instance IP address
If a custom DNS name is provided, resolve it to an IP address (A record) and use it for the connection. Fallback to metadata IP if resolution fails. Fixes #513
1 parent b67dc7b commit 3282ed3

4 files changed

Lines changed: 194 additions & 6 deletions

File tree

src/cloud-sql-instance.ts

Lines changed: 23 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,16 @@ 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+
);
77+
if (options.domainName && !instanceInfo.domainName) {
78+
instanceInfo.domainName = options.domainName;
79+
}
7280
const instance = new CloudSQLInstance({
7381
options: options,
74-
instanceInfo: await resolveInstanceName(
75-
options.instanceConnectionName,
76-
options.domainName
77-
),
82+
instanceInfo,
7883
});
7984
await instance.refresh();
8085
return instance;
@@ -266,7 +271,20 @@ export class CloudSQLInstance {
266271
rsaKeys.publicKey,
267272
this.authType
268273
);
269-
const host = selectIpAddress(metadata.ipAddresses, this.ipType);
274+
let host;
275+
if (this.instanceInfo && this.instanceInfo.domainName) {
276+
try {
277+
const ips = await resolveARecord(this.instanceInfo.domainName);
278+
if (ips && ips.length > 0) {
279+
host = ips[0];
280+
}
281+
} catch (e) {
282+
// ignore error, fallback to metadata IP
283+
}
284+
}
285+
if (!host) {
286+
host = selectIpAddress(metadata.ipAddresses, this.ipType);
287+
}
270288
const privateKey = rsaKeys.privateKey;
271289
const serverCaCert = metadata.serverCaCert;
272290
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: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 (name: string): Promise<string[]> => {
45+
return [];
46+
};
47+
48+
const {CloudSQLInstance} = t.mockRequire('../src/cloud-sql-instance', {
49+
'../src/crypto': {
50+
generateKeys: async () => ({
51+
publicKey: '-----BEGIN PUBLIC KEY-----',
52+
privateKey: CLIENT_KEY,
53+
}),
54+
},
55+
'../src/time': {
56+
getRefreshInterval() {
57+
return 50;
58+
},
59+
isExpirationTimeValid() {
60+
return true;
61+
},
62+
},
63+
'../src/dns-lookup': {
64+
resolveARecord: async (name: string) => resolveARecordMock(name),
65+
resolveInstanceName: async () => ({
66+
projectId: 'my-project',
67+
regionId: 'us-east1',
68+
instanceId: 'my-instance',
69+
domainName: 'example.com',
70+
}),
71+
},
72+
});
73+
74+
t.test('should use resolved IP when domainName is present', async t => {
75+
const expectedIp = '10.0.0.1';
76+
resolveARecordMock = async (name: string) => {
77+
t.equal(name, 'example.com');
78+
return [expectedIp];
79+
};
80+
81+
const instance = await CloudSQLInstance.getCloudSQLInstance({
82+
ipType: IpAddressTypes.PUBLIC,
83+
authType: AuthTypes.PASSWORD,
84+
instanceConnectionName: 'my-project:us-east1:my-instance',
85+
domainName: 'example.com',
86+
sqlAdminFetcher: fetcher,
87+
});
88+
t.after(() => instance.close());
89+
90+
t.equal(instance.host, expectedIp, 'Host should match resolved IP');
91+
});
92+
93+
t.test('should fallback to metadata IP when resolution fails', async t => {
94+
resolveARecordMock = async (name: string) => {
95+
throw new Error('DNS Error');
96+
};
97+
98+
const instance = await CloudSQLInstance.getCloudSQLInstance({
99+
ipType: IpAddressTypes.PUBLIC,
100+
authType: AuthTypes.PASSWORD,
101+
instanceConnectionName: 'my-project:us-east1:my-instance',
102+
domainName: 'example.com',
103+
sqlAdminFetcher: fetcher,
104+
});
105+
t.after(() => instance.close());
106+
107+
t.equal(instance.host, '127.0.0.1', 'Host should fallback to metadata IP');
108+
});
109+
110+
t.test('should fallback to metadata IP when resolution returns empty', async t => {
111+
resolveARecordMock = async (name: string) => {
112+
return [];
113+
};
114+
115+
const instance = await CloudSQLInstance.getCloudSQLInstance({
116+
ipType: IpAddressTypes.PUBLIC,
117+
authType: AuthTypes.PASSWORD,
118+
instanceConnectionName: 'my-project:us-east1:my-instance',
119+
domainName: 'example.com',
120+
sqlAdminFetcher: fetcher,
121+
});
122+
t.after(() => instance.close());
123+
124+
t.equal(instance.host, '127.0.0.1', 'Host should fallback to metadata IP');
125+
});
126+
127+
t.test('should use metadata IP when domainName is not present', async t => {
128+
// resolveARecord should not be called, but if it is, ensure it doesn't return the expected IP
129+
resolveARecordMock = async (name: string) => {
130+
t.fail('Should not attempt to resolve DNS');
131+
return ['10.0.0.1'];
132+
};
133+
134+
const instance = await CloudSQLInstance.getCloudSQLInstance({
135+
ipType: IpAddressTypes.PUBLIC,
136+
authType: AuthTypes.PASSWORD,
137+
instanceConnectionName: 'my-project:us-east1:my-instance',
138+
sqlAdminFetcher: fetcher,
139+
});
140+
t.after(() => instance.close());
141+
142+
t.equal(instance.host, '127.0.0.1', 'Host should use metadata IP');
143+
});
144+
});

test/dns-lookup.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
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', {
18+
const {resolveTxtRecord, resolveARecord} = t.mockRequire('../src/dns-lookup.ts', {
1919
'node:dns': {
2020
resolveTxt: (name, callback) => {
2121
switch (name) {
@@ -39,6 +39,15 @@ t.test('lookup dns with mock responses', async t => {
3939
return;
4040
}
4141
},
42+
resolve4: (name, callback) => {
43+
if (name === 'example.com') {
44+
callback(null, ['10.0.0.1']);
45+
} else if (name === 'empty.example.com') {
46+
callback(null, []);
47+
} else {
48+
callback(new Error('not found'));
49+
}
50+
},
4251
},
4352
});
4453

@@ -67,6 +76,11 @@ t.test('lookup dns with mock responses', async t => {
6776
{code: 'EDOMAINNAMELOOKUPFAILED'},
6877
'should throw type error if an extra item is provided'
6978
);
79+
80+
// resolveARecord tests
81+
t.same(await resolveARecord('example.com'), ['10.0.0.1'], 'should resolve A record');
82+
t.same(await resolveARecord('empty.example.com'), [], 'should return empty array');
83+
t.rejects(async () => await resolveARecord('not-found'), /not found/, 'should reject on error');
7084
});
7185

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

0 commit comments

Comments
 (0)