Skip to content

Commit ffbb4af

Browse files
committed
chore: Resolve DNS TXT record to instance name. Part of DNS Config.
1 parent 769b065 commit ffbb4af

File tree

4 files changed

+292
-0
lines changed

4 files changed

+292
-0
lines changed

src/dns-lookup.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 dns from 'node:dns';
16+
import {CloudSQLConnectorError} from './errors';
17+
18+
export async function resolveTxtRecord(name: string): Promise<string> {
19+
return new Promise((resolve, reject) => {
20+
dns.resolveTxt(name, (err, addresses) => {
21+
if (err) {
22+
reject(
23+
new CloudSQLConnectorError({
24+
code: 'EDOMAINNAMELOOKUPERROR',
25+
message: 'Error looking up TXT record for domain ' + name,
26+
errors: [err],
27+
})
28+
);
29+
return;
30+
}
31+
32+
if (!addresses || addresses.length === 0) {
33+
reject(
34+
new CloudSQLConnectorError({
35+
code: 'EDOMAINNAMELOOKUPFAILED',
36+
message: 'No records returned for domain ' + name,
37+
})
38+
);
39+
return;
40+
}
41+
42+
// Each result may be split into multiple strings. Join the strings.
43+
const joinedAddresses = addresses.map(strs => strs.join(''));
44+
// Sort the results alphabetically for consistency,
45+
joinedAddresses.sort((a, b) => a.localeCompare(b));
46+
// Return the first result.
47+
resolve(joinedAddresses[0]);
48+
});
49+
});
50+
}

src/parse-instance-connection-name.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,29 @@
1414

1515
import {InstanceConnectionInfo} from './instance-connection-info';
1616
import {CloudSQLConnectorError} from './errors';
17+
import {resolveTxtRecord} from './dns-lookup';
18+
19+
export async function resolveInstanceName(
20+
name: string | undefined
21+
): Promise<InstanceConnectionInfo> {
22+
if (!name) {
23+
throw new CloudSQLConnectorError({
24+
message:
25+
'Missing instance connection name, expected: "PROJECT:REGION:INSTANCE"',
26+
code: 'ENOCONNECTIONNAME',
27+
});
28+
} else if (isInstanceConnectionName(name)) {
29+
return parseInstanceConnectionName(name);
30+
} else if (isValidDomainName(name)) {
31+
return await resolveDomainName(name);
32+
} else {
33+
throw new CloudSQLConnectorError({
34+
message:
35+
'Malformed Instance connection name, expected an instance connection name in the form "PROJECT:REGION:INSTANCE" or a valid domain name',
36+
code: 'EBADCONNECTIONNAME',
37+
});
38+
}
39+
}
1740

1841
const connectionNameRegex =
1942
/^(?<projectId>[^:]+(:[^:]+)?):(?<regionId>[^:]+):(?<instanceId>[^:]+)$/;
@@ -33,6 +56,26 @@ export function isInstanceConnectionName(name: string): boolean {
3356
return Boolean(matches);
3457
}
3558

59+
export async function resolveDomainName(
60+
name: string
61+
): Promise<InstanceConnectionInfo> {
62+
const icn = await resolveTxtRecord(name);
63+
if (!isInstanceConnectionName(icn)) {
64+
throw new CloudSQLConnectorError({
65+
message:
66+
'Malformed instance connection name returned for domain ' +
67+
name +
68+
' : ' +
69+
icn,
70+
code: 'EBADDOMAINCONNECTIONNAME',
71+
});
72+
}
73+
74+
const info = parseInstanceConnectionName(icn);
75+
info.domainName = name;
76+
return info;
77+
}
78+
3679
export function parseInstanceConnectionName(
3780
instanceConnectionName: string | undefined
3881
): InstanceConnectionInfo {

test/dns-lookup.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
17+
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':
35+
callback(null, []);
36+
return;
37+
default:
38+
callback(new Error('not found'), null);
39+
return;
40+
}
41+
},
42+
},
43+
});
44+
45+
t.same(
46+
await resolveTxtRecord('db.example.com'),
47+
'my-project:region-1:instance',
48+
'valid domain name'
49+
);
50+
t.same(
51+
await resolveTxtRecord('split.example.com'),
52+
'my-project:region-1:instance',
53+
'valid domain name'
54+
);
55+
t.same(
56+
await resolveTxtRecord('multiple.example.com'),
57+
'another-project:region-1:instance',
58+
'valid domain name'
59+
);
60+
t.rejects(
61+
async () => await resolveTxtRecord('not-found.example.com'),
62+
{code: 'EDOMAINNAMELOOKUPERROR'},
63+
'should throw type error if an extra item is provided'
64+
);
65+
t.rejects(
66+
async () => await resolveTxtRecord('empty.example.com'),
67+
{code: 'EDOMAINNAMELOOKUPFAILED'},
68+
'should throw type error if an extra item is provided'
69+
);
70+
});
71+
72+
t.test('lookup dns with real responses', async t => {
73+
const {resolveTxtRecord} = t.mockRequire('../src/dns-lookup.ts', {});
74+
t.same(
75+
await resolveTxtRecord('valid-san-test.csqlconnectortest.com'),
76+
'cloud-sql-connector-testing:us-central1:postgres-customer-cas-test',
77+
'valid domain name'
78+
);
79+
});

test/parse-instance-connection-name.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
isValidDomainName,
1919
isInstanceConnectionName,
2020
} from '../src/parse-instance-connection-name';
21+
import {CloudSQLConnectorError} from '../src/errors';
2122

2223
t.throws(
2324
() => parseInstanceConnectionName(undefined),
@@ -92,6 +93,7 @@ t.same(
9293
);
9394

9495
t.same(isValidDomainName('project.example.com'), true, 'valid domain name');
96+
t.same(isValidDomainName('project.example.com.'), true, 'valid domain name');
9597

9698
t.same(isValidDomainName('google.com:PROJECT'), false, 'invalid domain name');
9799

@@ -106,3 +108,121 @@ t.same(
106108
false,
107109
'should validate domain name'
108110
);
111+
112+
t.test('resolveDomainName Mock DNS', async () => {
113+
// mocks crypto module so that it can return a deterministic result
114+
// and set a standard, fast static value for cert refresh interval
115+
const {resolveDomainName} = t.mockRequire(
116+
'../src/parse-instance-connection-name',
117+
{
118+
'../src/dns-lookup': {
119+
resolveTxtRecord: async name => {
120+
switch (name) {
121+
case 'db.example.com':
122+
return 'my-project:region-1:my-instance';
123+
case 'bad.example.com':
124+
return 'bad-instance-name';
125+
default:
126+
throw new CloudSQLConnectorError({
127+
code: 'EDOMAINNAMELOOKUPERROR',
128+
message: 'Error looking up TXT record for domain ' + name,
129+
});
130+
}
131+
},
132+
},
133+
}
134+
);
135+
136+
t.same(
137+
await resolveDomainName('db.example.com'),
138+
{
139+
projectId: 'my-project',
140+
regionId: 'region-1',
141+
instanceId: 'my-instance',
142+
domainName: 'db.example.com',
143+
},
144+
'should validate domain name'
145+
);
146+
147+
t.rejects(
148+
async () => await resolveDomainName('bad.example.com'),
149+
{code: 'EBADDOMAINCONNECTIONNAME'},
150+
'should throw type error if an extra item is provided'
151+
);
152+
153+
t.rejects(
154+
async () => await resolveDomainName('no-record.example.com'),
155+
{code: 'EDOMAINNAMELOOKUPERROR'},
156+
'should throw type error if an extra item is provided'
157+
);
158+
});
159+
160+
t.test('resolveInstanceName Mock DNS', async () => {
161+
// mocks crypto module so that it can return a deterministic result
162+
// and set a standard, fast static value for cert refresh interval
163+
const {resolveInstanceName} = t.mockRequire(
164+
'../src/parse-instance-connection-name',
165+
{
166+
'../src/dns-lookup': {
167+
resolveTxtRecord: async name => {
168+
switch (name) {
169+
case 'db.example.com':
170+
return 'my-project:region-1:my-instance';
171+
case 'bad.example.com':
172+
return 'bad-instance-name';
173+
default:
174+
throw new CloudSQLConnectorError({
175+
code: 'EDOMAINNAMELOOKUPERROR',
176+
message: 'Error looking up TXT record for domain ' + name,
177+
});
178+
}
179+
},
180+
},
181+
}
182+
);
183+
184+
t.same(
185+
await resolveInstanceName('db.example.com'),
186+
{
187+
projectId: 'my-project',
188+
regionId: 'region-1',
189+
instanceId: 'my-instance',
190+
domainName: 'db.example.com',
191+
},
192+
'should use domain name'
193+
);
194+
195+
t.same(
196+
await resolveInstanceName('my-project:region-1:my-instance'),
197+
{
198+
projectId: 'my-project',
199+
regionId: 'region-1',
200+
instanceId: 'my-instance',
201+
domainName: undefined,
202+
},
203+
'should use instance name'
204+
);
205+
206+
t.rejects(
207+
async () => await resolveInstanceName('bad.example.com'),
208+
{code: 'EBADDOMAINCONNECTIONNAME'},
209+
'should throw type error if an extra item is provided'
210+
);
211+
212+
t.rejects(
213+
async () => await resolveInstanceName('no-record.example.com'),
214+
{code: 'EDOMAINNAMELOOKUPERROR'},
215+
'should throw type error if an extra item is provided'
216+
);
217+
218+
t.rejects(
219+
async () => await resolveInstanceName(''),
220+
{code: 'ENOCONNECTIONNAME'},
221+
'should throw type error if the connection name is empty'
222+
);
223+
t.rejects(
224+
async () => await resolveInstanceName('bad-name'),
225+
{code: 'EBADCONNECTIONNAME'},
226+
'should throw type error if the connection name is empty'
227+
);
228+
});

0 commit comments

Comments
 (0)