Skip to content

Commit 3cd89b1

Browse files
committed
feat: Automatically configure connections using DNS. Part of #2043.
1 parent c389387 commit 3cd89b1

File tree

9 files changed

+435
-54
lines changed

9 files changed

+435
-54
lines changed

.github/workflows/tests.yml

+8
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ jobs:
142142
POSTGRES_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_PASS
143143
POSTGRES_CUSTOMER_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_CONNECTION_NAME
144144
POSTGRES_CUSTOMER_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS
145+
POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME
146+
POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME
145147
SQLSERVER_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_CONNECTION_NAME
146148
SQLSERVER_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_USER
147149
SQLSERVER_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_PASS
@@ -166,6 +168,8 @@ jobs:
166168
POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}"
167169
POSTGRES_CUSTOMER_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_CONNECTION_NAME }}"
168170
POSTGRES_CUSTOMER_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS }}"
171+
POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME }}"
172+
POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME }}"
169173
SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}"
170174
SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}"
171175
SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}"
@@ -249,6 +253,8 @@ jobs:
249253
POSTGRES_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_PASS
250254
POSTGRES_CUSTOMER_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_CONNECTION_NAME
251255
POSTGRES_CUSTOMER_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS
256+
POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME
257+
POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME
252258
SQLSERVER_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_CONNECTION_NAME
253259
SQLSERVER_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_USER
254260
SQLSERVER_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_PASS
@@ -274,6 +280,8 @@ jobs:
274280
POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}"
275281
POSTGRES_CUSTOMER_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_CONNECTION_NAME }}"
276282
POSTGRES_CUSTOMER_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS }}"
283+
POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME }}"
284+
POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME: "${{ steps.secrets.outputs.POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME }}"
277285
SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}"
278286
SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}"
279287
SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}"

core/src/main/java/com/google/cloud/sql/core/Connector.java

+10-7
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ class Connector {
4949
private final int serverProxyPort;
5050
private final ConnectorConfig config;
5151

52+
private final InstanceConnectionNameResolver instanceNameResolver;
53+
5254
Connector(
5355
ConnectorConfig config,
5456
ConnectionInfoRepositoryFactory connectionInfoRepositoryFactory,
@@ -57,7 +59,8 @@ class Connector {
5759
ListenableFuture<KeyPair> localKeyPair,
5860
long minRefreshDelayMs,
5961
long refreshTimeoutMs,
60-
int serverProxyPort) {
62+
int serverProxyPort,
63+
InstanceConnectionNameResolver instanceNameResolver) {
6164
this.config = config;
6265

6366
this.adminApi =
@@ -67,6 +70,7 @@ class Connector {
6770
this.localKeyPair = localKeyPair;
6871
this.minRefreshDelayMs = minRefreshDelayMs;
6972
this.serverProxyPort = serverProxyPort;
73+
this.instanceNameResolver = instanceNameResolver;
7074
}
7175

7276
public ConnectorConfig getConfig() {
@@ -181,17 +185,16 @@ private ConnectionConfig resolveConnectionName(ConnectionConfig config) {
181185
final String unresolvedName = config.getDomainName();
182186
final Function<String, String> resolver =
183187
config.getConnectorConfig().getInstanceNameResolver();
188+
CloudSqlInstanceName name;
184189
if (resolver != null) {
185-
return config.withCloudSqlInstance(resolver.apply(unresolvedName));
190+
name = instanceNameResolver.resolve(resolver.apply(unresolvedName));
186191
} else {
187-
throw new IllegalStateException(
188-
"Can't resolve domain " + unresolvedName + ". ConnectorConfig.resolver is not set.");
192+
name = instanceNameResolver.resolve(unresolvedName);
189193
}
194+
return config.withCloudSqlInstance(name.getConnectionName());
190195
} catch (IllegalArgumentException e) {
191196
throw new IllegalArgumentException(
192-
String.format(
193-
"Cloud SQL connection name is invalid: \"%s\"", config.getCloudSqlInstance()),
194-
e);
197+
String.format("Cloud SQL connection name is invalid: \"%s\"", config.getDomainName()), e);
195198
}
196199
}
197200

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.sql.core;
18+
19+
import java.util.Collection;
20+
import java.util.Objects;
21+
import javax.naming.NameNotFoundException;
22+
import org.slf4j.Logger;
23+
import org.slf4j.LoggerFactory;
24+
25+
/**
26+
* An implementation of InstanceConnectionNameResolver that uses DNS TXT records to resolve an
27+
* instance name from a domain name.
28+
*/
29+
class DnsInstanceConnectionNameResolver implements InstanceConnectionNameResolver {
30+
private static final Logger logger =
31+
LoggerFactory.getLogger(DnsInstanceConnectionNameResolver.class);
32+
33+
private final DnsResolver dnsResolver;
34+
35+
public DnsInstanceConnectionNameResolver(DnsResolver dnsResolver) {
36+
this.dnsResolver = dnsResolver;
37+
}
38+
39+
@Override
40+
public CloudSqlInstanceName resolve(final String name) {
41+
if (CloudSqlInstanceName.isValidInstanceName(name)) {
42+
// name contains a well-formed instance name.
43+
return new CloudSqlInstanceName(name);
44+
}
45+
46+
if (CloudSqlInstanceName.isValidDomain(name)) {
47+
// name contains a well-formed domain name.
48+
return resolveDomainName(name);
49+
}
50+
51+
// name is not well-formed, and therefore cannot be resolved.
52+
throw new IllegalArgumentException(
53+
String.format(
54+
"Unable to resolve database instance for \"%s\". It should be a "
55+
+ "well-formed instance name or domain name.",
56+
name));
57+
}
58+
59+
private CloudSqlInstanceName resolveDomainName(String name) {
60+
// Next, attempt to resolve DNS name.
61+
Collection<String> instanceNames;
62+
try {
63+
instanceNames = this.dnsResolver.resolveTxt(name);
64+
} catch (NameNotFoundException ne) {
65+
// No DNS record found. This is not a valid instance name.
66+
throw new IllegalArgumentException(
67+
String.format(
68+
"Unable to resolve TXT record containing the instance name for "
69+
+ "domain name \"%s\".",
70+
name));
71+
}
72+
73+
// Use the first valid instance name from the list
74+
// or throw an IllegalArgumentException if none of the values can be parsed.
75+
return instanceNames.stream()
76+
.map(
77+
target -> {
78+
try {
79+
return new CloudSqlInstanceName(target, name);
80+
} catch (IllegalArgumentException e) {
81+
logger.info(
82+
"Unable to parse instance name in TXT record for "
83+
+ "domain name \"{}\" with target \"{}\"",
84+
name,
85+
target,
86+
e);
87+
return null;
88+
}
89+
})
90+
.filter(Objects::nonNull)
91+
.findFirst()
92+
.orElseThrow(
93+
() ->
94+
new IllegalArgumentException(
95+
String.format("Unable to parse values of TXT record for \"%s\".", name)));
96+
}
97+
}

core/src/main/java/com/google/cloud/sql/core/DnsResolver.java

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.Collection;
2020
import javax.naming.NameNotFoundException;
2121

22+
/** Wraps the Java DNS API. */
2223
interface DnsResolver {
2324
Collection<String> resolveTxt(String domainName) throws NameNotFoundException;
2425
}

core/src/main/java/com/google/cloud/sql/core/InstanceCheckingTrustManger.java

+15-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.cloud.sql.core;
1818

19+
import com.google.common.base.Strings;
1920
import java.net.Socket;
2021
import java.security.cert.CertificateException;
2122
import java.security.cert.X509Certificate;
@@ -103,20 +104,31 @@ private void checkCertificateChain(X509Certificate[] chain) throws CertificateEx
103104
}
104105

105106
private void checkSan(X509Certificate[] chain) throws CertificateException {
106-
List<String> sans = getSans(chain[0]);
107-
String dns = instanceMetadata.getDnsName();
107+
final String dns;
108+
if (!Strings.isNullOrEmpty(instanceMetadata.getInstanceName().getDomainName())) {
109+
// If the connector is configured using a DNS name, validate the DNS name from the connector
110+
// config.
111+
dns = instanceMetadata.getInstanceName().getDomainName();
112+
} else {
113+
// If the connector is configured with an instance name, validate the DNS name from
114+
// the instance metadata.
115+
dns = instanceMetadata.getDnsName();
116+
}
117+
108118
if (dns == null || dns.isEmpty()) {
109119
throw new CertificateException(
110120
"Instance metadata for " + instanceMetadata.getInstanceName() + " has an empty dnsName");
111121
}
122+
123+
List<String> sans = getSans(chain[0]);
112124
for (String san : sans) {
113125
if (san.equalsIgnoreCase(dns)) {
114126
return;
115127
}
116128
}
117129
throw new CertificateException(
118130
"Server certificate does not contain expected name '"
119-
+ instanceMetadata.getDnsName()
131+
+ dns
120132
+ "' for Cloud SQL instance "
121133
+ instanceMetadata.getInstanceName());
122134
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.sql.core;
18+
19+
/** Resolves the Cloud SQL Instance from the configuration name. */
20+
interface InstanceConnectionNameResolver {
21+
22+
/**
23+
* Resolves the CloudSqlInstanceName from a configuration string value.
24+
*
25+
* @param name the configuration string
26+
* @return the CloudSqlInstanceName
27+
* @throws IllegalArgumentException if the name cannot be resolved.
28+
*/
29+
CloudSqlInstanceName resolve(String name);
30+
}

core/src/main/java/com/google/cloud/sql/core/InternalConnectorRegistry.java

+7-4
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,11 @@ public Socket connect(ConnectionConfig config) throws IOException, InterruptedEx
172172

173173
// Validate parameters
174174
Preconditions.checkArgument(
175-
config.getCloudSqlInstance() != null,
176-
"cloudSqlInstance property not set. Please specify this property in the JDBC URL or the "
177-
+ "connection Properties with value in form \"project:region:instance\"");
175+
config.getCloudSqlInstance() != null || config.getDomainName() != null,
176+
"cloudSqlInstance property or hostname was not set. Please specify"
177+
+ " either cloudSqlInstance or the database hostname in the JDBC URL or the "
178+
+ "connection Properties. cloudSqlInstance should contain a value in "
179+
+ "form \"project:region:instance\"");
178180

179181
return getConnector(config).connect(config, connectTimeoutMs);
180182
}
@@ -332,7 +334,8 @@ private Connector createConnector(ConnectorConfig config) {
332334
localKeyPair,
333335
MIN_REFRESH_DELAY_MS,
334336
connectTimeoutMs,
335-
serverProxyPort);
337+
serverProxyPort,
338+
new DnsInstanceConnectionNameResolver(new JndiDnsResolver()));
336339
}
337340

338341
/** Register the configuration for a named connector. */

0 commit comments

Comments
 (0)