Skip to content

Commit cb745f2

Browse files
authoredJan 23, 2025··
feat: Automatically configure connections using DNS. Part of #2043. (#2047)
The dialer may be configured to use a DNS name to look up the instance name instead of configuring the connector with the instance name directly. Add a DNS TXT record for the Cloud SQL instance to a private DNS server or a private Google Cloud DNS Zone used by your application. For example: Record type: TXT Name: prod-db.mycompany.example.com – This is the domain name used by the application Value: my-project:region:my-instance – This is the instance connection name Configure the dialer with the cloudsqlconn.WithDNSResolver() option. Open a database connection using the DNS name: HOST: The domain name configured in your DNS TXT record. Base JDBC URL: jdbc:mysql://<HOST>/<DATABASE_NAME> SOCKET_FACTORY_CLASS: com.google.cloud.sql.mysql.SocketFactory The full JDBC URL for MySQL should look like this: String jdbcUrl = "jdbc:mysql://<HOST>/<DATABASE_NAME>?" + "&socketFactory=com.google.cloud.sql.mysql.SocketFactory" + "&user=<MYSQL_USER_NAME>" + "&password=<MYSQL_USER_PASSWORD>"; See also: the Cloud SQL Go Connector implementation Part of #2043
1 parent 1f19f2b commit cb745f2

File tree

9 files changed

+436
-55
lines changed

9 files changed

+436
-55
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

+16-4
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();
108-
if (dns == null || dns.isEmpty()) {
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+
118+
if (Strings.isNullOrEmpty(dns)) {
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. */

‎core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java

+133-40
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
import java.security.cert.X509Certificate;
3737
import java.time.Duration;
3838
import java.time.Instant;
39+
import java.util.Collection;
3940
import java.util.Collections;
41+
import javax.naming.NameNotFoundException;
4042
import javax.net.ssl.SSLHandshakeException;
4143
import org.junit.After;
4244
import org.junit.Before;
@@ -71,7 +73,8 @@ public void create_throwsErrorForInvalidInstanceName() throws IOException {
7173
.withIpTypes("PRIMARY")
7274
.build();
7375

74-
Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT);
76+
Connector c =
77+
newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null, false);
7578
IllegalArgumentException ex =
7679
assertThrows(IllegalArgumentException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
7780

@@ -96,7 +99,7 @@ public void create_throwsErrorForInvalidTlsCommonNameMismatch()
9699

97100
int port = sslServer.start(PUBLIC_IP);
98101

99-
Connector connector = newConnector(config.getConnectorConfig(), port);
102+
Connector connector = newConnector(config.getConnectorConfig(), port, null, null, false);
100103
SSLHandshakeException ex =
101104
assertThrows(
102105
SSLHandshakeException.class, () -> connector.connect(config, TEST_MAX_REFRESH_MS));
@@ -124,30 +127,25 @@ public void create_successfulPrivateConnection() throws IOException, Interrupted
124127

125128
int port = sslServer.start(PRIVATE_IP);
126129

127-
Connector connector = newConnector(config.getConnectorConfig(), port);
130+
Connector connector = newConnector(config.getConnectorConfig(), port, null, null, false);
128131

129132
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
130133

131134
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
132135
}
133136

134137
@Test
135-
public void create_successfulPublicConnectionWithDomainName()
136-
throws IOException, InterruptedException {
138+
public void create_successfulPublicConnection() throws IOException, InterruptedException {
137139
FakeSslServer sslServer = new FakeSslServer();
138140
ConnectionConfig config =
139141
new ConnectionConfig.Builder()
140-
.withDomainName("db.example.com")
142+
.withCloudSqlInstance("myProject:myRegion:myInstance")
141143
.withIpTypes("PRIMARY")
142-
.withConnectorConfig(
143-
new ConnectorConfig.Builder()
144-
.withInstanceNameResolver((domainName) -> "myProject:myRegion:myInstance")
145-
.build())
146144
.build();
147145

148146
int port = sslServer.start(PUBLIC_IP);
149147

150-
Connector connector = newConnector(config.getConnectorConfig(), port);
148+
Connector connector = newConnector(config.getConnectorConfig(), port, null, null, false);
151149

152150
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
153151

@@ -167,7 +165,7 @@ public void create_successfulPrivateConnection_UsesInstanceName_DomainNameIgnore
167165

168166
int port = sslServer.start(PRIVATE_IP);
169167

170-
Connector connector = newConnector(config.getConnectorConfig(), port);
168+
Connector connector = newConnector(config.getConnectorConfig(), port, null, null, false);
171169

172170
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
173171

@@ -187,17 +185,16 @@ public void create_successfulPrivateConnection_UsesInstanceName_EmptyDomainNameI
187185

188186
int port = sslServer.start(PRIVATE_IP);
189187

190-
Connector connector = newConnector(config.getConnectorConfig(), port);
188+
Connector connector = newConnector(config.getConnectorConfig(), port, null, null, false);
191189

192190
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
193191

194192
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
195193
}
196194

197195
@Test
198-
public void create_throwsErrorForDomainNameWithNoResolver()
196+
public void create_successfulPublicConnectionWithDomainName()
199197
throws IOException, InterruptedException {
200-
// The server TLS certificate matches myProject:myRegion:myInstance
201198
FakeSslServer sslServer = new FakeSslServer();
202199
ConnectionConfig config =
203200
new ConnectionConfig.Builder()
@@ -207,30 +204,81 @@ public void create_throwsErrorForDomainNameWithNoResolver()
207204

208205
int port = sslServer.start(PUBLIC_IP);
209206

210-
Connector connector = newConnector(config.getConnectorConfig(), port);
211-
IllegalStateException ex =
212-
assertThrows(
213-
IllegalStateException.class, () -> connector.connect(config, TEST_MAX_REFRESH_MS));
207+
Connector connector =
208+
newConnector(
209+
config.getConnectorConfig(),
210+
port,
211+
"db.example.com",
212+
"myProject:myRegion:myInstance",
213+
false);
214+
215+
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
214216

215-
assertThat(ex).hasMessageThat().contains("ConnectorConfig.resolver is not set");
217+
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
216218
}
217219

218220
@Test
219-
public void create_successfulPublicConnection() throws IOException, InterruptedException {
221+
public void create_throwsErrorForUnresolvedDomainName() throws IOException {
222+
ConnectionConfig config =
223+
new ConnectionConfig.Builder()
224+
.withDomainName("baddomain.example.com")
225+
.withIpTypes("PRIMARY")
226+
.build();
227+
Connector c =
228+
newConnector(
229+
config.getConnectorConfig(),
230+
DEFAULT_SERVER_PROXY_PORT,
231+
"baddomain.example.com",
232+
"invalid-name",
233+
false);
234+
RuntimeException ex =
235+
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
236+
237+
assertThat(ex)
238+
.hasMessageThat()
239+
.contains("Cloud SQL connection name is invalid: \"baddomain.example.com\"");
240+
}
241+
242+
@Test
243+
public void create_throwsErrorForDomainNameBadTargetValue() throws IOException {
244+
ConnectionConfig config =
245+
new ConnectionConfig.Builder()
246+
.withDomainName("badvalue.example.com")
247+
.withIpTypes("PRIMARY")
248+
.build();
249+
Connector c =
250+
newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null, false);
251+
RuntimeException ex =
252+
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
253+
254+
assertThat(ex)
255+
.hasMessageThat()
256+
.contains("Cloud SQL connection name is invalid: \"badvalue.example.com\"");
257+
}
258+
259+
@Test
260+
public void create_throwsErrorForDomainNameDoesntMatchServerCert() throws Exception {
220261
FakeSslServer sslServer = new FakeSslServer();
221262
ConnectionConfig config =
222263
new ConnectionConfig.Builder()
223-
.withCloudSqlInstance("myProject:myRegion:myInstance")
264+
.withDomainName("not-in-san.example.com")
224265
.withIpTypes("PRIMARY")
225266
.build();
226267

227268
int port = sslServer.start(PUBLIC_IP);
228269

229-
Connector connector = newConnector(config.getConnectorConfig(), port);
270+
Connector c =
271+
newConnector(
272+
config.getConnectorConfig(),
273+
port,
274+
"db.example.com",
275+
"myProject:myRegion:myInstance",
276+
true);
230277

231-
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
278+
SSLHandshakeException ex =
279+
assertThrows(SSLHandshakeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
232280

233-
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
281+
assertThat(ex).hasMessageThat().contains("Server certificate does not contain expected name");
234282
}
235283

236284
@Test
@@ -259,7 +307,8 @@ public void create_successfulPublicCasConnection() throws IOException, Interrupt
259307
clientKeyPair,
260308
10,
261309
TEST_MAX_REFRESH_MS,
262-
port);
310+
port,
311+
null);
263312

264313
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
265314

@@ -293,7 +342,7 @@ public void create_successfulUnixSocketConnection() throws IOException, Interrup
293342

294343
unixSocketServer.start();
295344

296-
Connector connector = newConnector(config.getConnectorConfig(), 10000);
345+
Connector connector = newConnector(config.getConnectorConfig(), 10000, null, null, false);
297346

298347
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
299348

@@ -329,7 +378,8 @@ public void create_successfulDomainScopedConnection() throws IOException, Interr
329378
clientKeyPair,
330379
10,
331380
TEST_MAX_REFRESH_MS,
332-
port);
381+
port,
382+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
333383

334384
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
335385

@@ -343,7 +393,8 @@ public void create_throwsErrorForInvalidInstanceRegion() throws IOException {
343393
.withCloudSqlInstance("myProject:notMyRegion:myInstance")
344394
.withIpTypes("PRIMARY")
345395
.build();
346-
Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT);
396+
Connector c =
397+
newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null, false);
347398
RuntimeException ex =
348399
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
349400

@@ -367,7 +418,9 @@ public void create_failOnEmptyTargetPrincipal() throws IOException, InterruptedE
367418
IllegalArgumentException ex =
368419
assertThrows(
369420
IllegalArgumentException.class,
370-
() -> newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT));
421+
() ->
422+
newConnector(
423+
config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null, false));
371424

372425
assertThat(ex.getMessage()).contains(ConnectionConfig.CLOUD_SQL_TARGET_PRINCIPAL_PROPERTY);
373426
}
@@ -390,7 +443,8 @@ public void create_throwsException_adminApiNotEnabled() throws IOException {
390443
clientKeyPair,
391444
10,
392445
TEST_MAX_REFRESH_MS,
393-
DEFAULT_SERVER_PROXY_PORT);
446+
DEFAULT_SERVER_PROXY_PORT,
447+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
394448

395449
// Use a different project to get Api Not Enabled Error.
396450
TerminalException ex =
@@ -422,7 +476,8 @@ public void create_throwsException_adminApiReturnsNotAuthorized() throws IOExcep
422476
clientKeyPair,
423477
10,
424478
TEST_MAX_REFRESH_MS,
425-
DEFAULT_SERVER_PROXY_PORT);
479+
DEFAULT_SERVER_PROXY_PORT,
480+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
426481

427482
// Use a different instance to simulate incorrect permissions.
428483
TerminalException ex =
@@ -454,7 +509,8 @@ public void create_throwsException_badGateway() throws IOException {
454509
clientKeyPair,
455510
10,
456511
TEST_MAX_REFRESH_MS,
457-
DEFAULT_SERVER_PROXY_PORT);
512+
DEFAULT_SERVER_PROXY_PORT,
513+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
458514

459515
// If the gateway is down, then this is a temporary error, not a fatal error.
460516
RuntimeException ex =
@@ -496,7 +552,8 @@ public void create_successfulPublicConnection_withIntermittentBadGatewayErrors()
496552
clientKeyPair,
497553
10,
498554
TEST_MAX_REFRESH_MS,
499-
port);
555+
port,
556+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
500557

501558
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
502559

@@ -529,7 +586,8 @@ public void supportsCustomCredentialFactoryWithIAM() throws InterruptedException
529586
clientKeyPair,
530587
10,
531588
TEST_MAX_REFRESH_MS,
532-
port);
589+
port,
590+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
533591

534592
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
535593

@@ -561,7 +619,8 @@ public void supportsCustomCredentialFactoryWithNoExpirationTime()
561619
clientKeyPair,
562620
10,
563621
TEST_MAX_REFRESH_MS,
564-
port);
622+
port,
623+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
565624

566625
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
567626

@@ -599,14 +658,18 @@ public HttpRequestInitializer create() {
599658
clientKeyPair,
600659
10,
601660
TEST_MAX_REFRESH_MS,
602-
DEFAULT_SERVER_PROXY_PORT);
661+
DEFAULT_SERVER_PROXY_PORT,
662+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
603663

604664
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
605665
}
606666

607-
private Connector newConnector(ConnectorConfig config, int port) {
667+
private Connector newConnector(
668+
ConnectorConfig config, int port, String domainName, String instanceName, boolean cas) {
608669
ConnectionInfoRepositoryFactory factory =
609-
new StubConnectionInfoRepositoryFactory(fakeSuccessHttpTransport(Duration.ofSeconds(0)));
670+
new StubConnectionInfoRepositoryFactory(
671+
fakeSuccessHttpTransport(
672+
TestKeys.getServerCertPem(), Duration.ofSeconds(0), null, cas, false));
610673
Connector connector =
611674
new Connector(
612675
config,
@@ -616,7 +679,8 @@ private Connector newConnector(ConnectorConfig config, int port) {
616679
clientKeyPair,
617680
10,
618681
TEST_MAX_REFRESH_MS,
619-
port);
682+
port,
683+
new DnsInstanceConnectionNameResolver(new MockDnsResolver(domainName, instanceName)));
620684
return connector;
621685
}
622686

@@ -625,4 +689,33 @@ private String readLine(Socket socket) throws IOException {
625689
new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
626690
return bufferedReader.readLine();
627691
}
692+
693+
private static class MockDnsResolver implements DnsResolver {
694+
private final String domainName;
695+
private final String instanceName;
696+
697+
public MockDnsResolver() {
698+
this.domainName = null;
699+
this.instanceName = null;
700+
}
701+
702+
public MockDnsResolver(String domainName, String instanceName) {
703+
this.domainName = domainName;
704+
this.instanceName = instanceName;
705+
}
706+
707+
@Override
708+
public Collection<String> resolveTxt(String domainName) throws NameNotFoundException {
709+
if (this.domainName != null && this.domainName.equals(domainName)) {
710+
return Collections.singletonList(this.instanceName);
711+
}
712+
if ("not-in-san.example.com".equals(domainName)) {
713+
return Collections.singletonList(this.instanceName);
714+
}
715+
if ("badvalue.example.com".equals(domainName)) {
716+
return Collections.singletonList("not-an-instance-name");
717+
}
718+
throw new NameNotFoundException("Not found: " + domainName);
719+
}
720+
}
628721
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
* https://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.postgres;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static com.google.common.truth.Truth.assertWithMessage;
21+
22+
import com.google.common.collect.ImmutableList;
23+
import com.zaxxer.hikari.HikariConfig;
24+
import com.zaxxer.hikari.HikariDataSource;
25+
import java.sql.*;
26+
import java.util.ArrayList;
27+
import java.util.List;
28+
import java.util.Properties;
29+
import java.util.concurrent.TimeUnit;
30+
import javax.net.ssl.SSLHandshakeException;
31+
import org.junit.Assert;
32+
import org.junit.Before;
33+
import org.junit.BeforeClass;
34+
import org.junit.Rule;
35+
import org.junit.Test;
36+
import org.junit.rules.Timeout;
37+
import org.junit.runner.RunWith;
38+
import org.junit.runners.JUnit4;
39+
40+
@RunWith(JUnit4.class)
41+
public class JdbcPostgresCustomSanIntegrationTests {
42+
43+
private static final String DOMAIN_NAME =
44+
System.getenv("POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME");
45+
private static final String INVALID_DOMAIN_NAME =
46+
System.getenv("POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME");
47+
private static final String DB_NAME = System.getenv("POSTGRES_DB");
48+
private static final String DB_USER = System.getenv("POSTGRES_USER");
49+
private static final String DB_PASSWORD = System.getenv("POSTGRES_CUSTOMER_CAS_PASS");
50+
private static final ImmutableList<String> requiredEnvVars =
51+
ImmutableList.of(
52+
"POSTGRES_USER",
53+
"POSTGRES_CUSTOMER_CAS_PASS",
54+
"POSTGRES_DB",
55+
"POSTGRES_CUSTOMER_CAS_PASS_VALID_DOMAIN_NAME",
56+
"POSTGRES_CUSTOMER_CAS_PASS_INVALID_DOMAIN_NAME");
57+
58+
@Rule public Timeout globalTimeout = new Timeout(80, TimeUnit.SECONDS);
59+
60+
private HikariDataSource connectionPool;
61+
62+
@BeforeClass
63+
public static void checkEnvVars() {
64+
// Check that required env vars are set
65+
requiredEnvVars.forEach(
66+
(varName) ->
67+
assertWithMessage(
68+
String.format(
69+
"Environment variable '%s' must be set to perform these tests.", varName))
70+
.that(System.getenv(varName))
71+
.isNotEmpty());
72+
}
73+
74+
@Before
75+
public void setUpPool() throws SQLException {
76+
// Set up URL parameters
77+
String jdbcURL = String.format("jdbc:postgresql://%s/%s", DOMAIN_NAME, DB_NAME);
78+
Properties connProps = new Properties();
79+
connProps.setProperty("user", DB_USER);
80+
connProps.setProperty("password", DB_PASSWORD);
81+
connProps.setProperty("socketFactory", "com.google.cloud.sql.postgres.SocketFactory");
82+
83+
// Initialize connection pool
84+
HikariConfig config = new HikariConfig();
85+
config.setJdbcUrl(jdbcURL);
86+
config.setDataSourceProperties(connProps);
87+
config.setConnectionTimeout(10000); // 10s
88+
89+
this.connectionPool = new HikariDataSource(config);
90+
}
91+
92+
@Test
93+
public void pooledConnectionTest() throws SQLException {
94+
95+
List<Timestamp> rows = new ArrayList<>();
96+
try (Connection conn = connectionPool.getConnection()) {
97+
try (PreparedStatement selectStmt = conn.prepareStatement("SELECT NOW() as TS")) {
98+
ResultSet rs = selectStmt.executeQuery();
99+
while (rs.next()) {
100+
rows.add(rs.getTimestamp("TS"));
101+
}
102+
}
103+
}
104+
assertThat(rows.size()).isEqualTo(1);
105+
}
106+
107+
@Test
108+
public void connectFailsWithInvalidName() throws SQLException {
109+
// Set up URL parameters
110+
String jdbcURL = String.format("jdbc:postgresql://%s/%s", INVALID_DOMAIN_NAME, DB_NAME);
111+
Properties connProps = new Properties();
112+
connProps.setProperty("user", DB_USER);
113+
connProps.setProperty("password", DB_PASSWORD);
114+
connProps.setProperty("socketFactory", "com.google.cloud.sql.postgres.SocketFactory");
115+
116+
// Initialize connection pool
117+
HikariConfig config = new HikariConfig();
118+
config.setJdbcUrl(jdbcURL);
119+
config.setDataSourceProperties(connProps);
120+
config.setConnectionTimeout(10000); // 10s
121+
122+
try {
123+
new HikariDataSource(config);
124+
Assert.fail("Connection");
125+
} catch (Exception e) {
126+
// connection failed, assert a tls error
127+
// Should throw
128+
// HikariException
129+
// caused by org.postgresql.util.PSQLException
130+
// caused by javax.net.ssl.SSLHandshakeException
131+
assertThat(e).hasCauseThat().hasCauseThat().isInstanceOf(SSLHandshakeException.class);
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)
Please sign in to comment.