Skip to content

Commit 59dc310

Browse files
committed
chore: add domainName to InstanceConnectionInfo, part of DNS Config.
chore: Resolve DNS TXT record to instance name. Part of DNS Config.
1 parent d838204 commit 59dc310

6 files changed

+444
-84
lines changed

src/dns-lookup.ts

+50
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/instance-connection-info.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export interface InstanceConnectionInfo {
1616
projectId: string;
1717
regionId: string;
1818
instanceId: string;
19+
domainName: string | undefined;
1920
}

src/parse-instance-connection-name.ts

+63-13
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,81 @@
1414

1515
import {InstanceConnectionInfo} from './instance-connection-info';
1616
import {CloudSQLConnectorError} from './errors';
17+
import {resolveTxtRecord} from './dns-lookup';
1718

18-
export function parseInstanceConnectionName(
19-
instanceConnectionName: string | undefined
20-
): InstanceConnectionInfo {
21-
if (!instanceConnectionName) {
19+
export async function resolveInstanceName(
20+
name: string | undefined
21+
): Promise<InstanceConnectionInfo> {
22+
if (!name) {
2223
throw new CloudSQLConnectorError({
2324
message:
2425
'Missing instance connection name, expected: "PROJECT:REGION:INSTANCE"',
2526
code: 'ENOCONNECTIONNAME',
2627
});
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+
});
2738
}
39+
}
2840

29-
const connectionNameRegex =
30-
/(?<projectId>[^:]+(:[^:]+)?):(?<regionId>[^:]+):(?<instanceId>[^:]+)/;
31-
const matches = String(instanceConnectionName).match(connectionNameRegex);
32-
if (!matches) {
41+
const connectionNameRegex =
42+
/^(?<projectId>[^:]+(:[^:]+)?):(?<regionId>[^:]+):(?<instanceId>[^:]+)$/;
43+
44+
// The domain name pattern in accordance with RFC 1035, RFC 1123 and RFC 2181.
45+
// From Go Connector:
46+
const domainNameRegex =
47+
/^(?:[_a-z0-9](?:[_a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?)?$/;
48+
49+
export function isValidDomainName(name: string): boolean {
50+
const matches = String(name).match(domainNameRegex);
51+
return Boolean(matches);
52+
}
53+
54+
export function isInstanceConnectionName(name: string): boolean {
55+
const matches = String(name).match(connectionNameRegex);
56+
return Boolean(matches);
57+
}
58+
59+
export async function resolveDomainName(
60+
name: string
61+
): Promise<InstanceConnectionInfo> {
62+
const icn = await resolveTxtRecord(name);
63+
if (!isInstanceConnectionName(icn)) {
3364
throw new CloudSQLConnectorError({
3465
message:
35-
'Malformed instance connection name provided: expected format ' +
36-
`of "PROJECT:REGION:INSTANCE", got ${instanceConnectionName}`,
37-
code: 'EBADCONNECTIONNAME',
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+
79+
export function parseInstanceConnectionName(
80+
instanceConnectionName: string | undefined
81+
): InstanceConnectionInfo {
82+
if (!instanceConnectionName) {
83+
throw new CloudSQLConnectorError({
84+
message:
85+
'Missing instance connection name, expected: "PROJECT:REGION:INSTANCE"',
86+
code: 'ENOCONNECTIONNAME',
3887
});
3988
}
4089

41-
const unmatchedItems = matches[0] !== matches.input;
42-
if (unmatchedItems || !matches.groups) {
90+
const matches = String(instanceConnectionName).match(connectionNameRegex);
91+
if (!matches || !matches.groups) {
4392
throw new CloudSQLConnectorError({
4493
message:
4594
'Malformed instance connection name provided: expected format ' +
@@ -52,5 +101,6 @@ export function parseInstanceConnectionName(
52101
projectId: matches.groups.projectId,
53102
regionId: matches.groups.regionId,
54103
instanceId: matches.groups.instanceId,
104+
domainName: undefined,
55105
};
56106
}

test/cloud-sql-instance.ts

+1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ t.test('CloudSQLInstance', async t => {
8080
projectId: 'my-project',
8181
regionId: 'us-east1',
8282
instanceId: 'my-instance',
83+
domainName: undefined,
8384
},
8485
'should have expected connection info'
8586
);

test/dns-lookup.ts

+79
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+
});

0 commit comments

Comments
 (0)