Skip to content

Commit 13f3828

Browse files
authored
feat: Automatically configure connections using DNS. Part of #2043. (#2044)
A connection may be configured to using a DNS name instead of an instance name. If the cloudSqlInstance property is set to be a domain name instead of an instance name, the connector will look up a SRV record for that name and connect to that instance. There should be exactly 1 SRV for the database instance. The connector will always use the SRV record with the highest priority. Part of #2043
1 parent cb75da8 commit 13f3828

File tree

8 files changed

+238
-15
lines changed

8 files changed

+238
-15
lines changed

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

+12
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,18 @@ public ConnectionConfig withConnectorConfig(ConnectorConfig config) {
200200
config);
201201
}
202202

203+
/** Creates a new instance of the ConnectionConfig with an updated cloudSqlInstance. */
204+
public ConnectionConfig withCloudSqlInstance(String newCloudSqlInstance) {
205+
return new ConnectionConfig(
206+
newCloudSqlInstance,
207+
namedConnector,
208+
unixSocketPath,
209+
ipTypes,
210+
authType,
211+
unixSocketPathSuffix,
212+
connectorConfig);
213+
}
214+
203215
public String getNamedConnector() {
204216
return namedConnector;
205217
}

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

+18-3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ class Connector {
4848
private final int serverProxyPort;
4949
private final ConnectorConfig config;
5050

51+
private final InstanceConnectionNameResolver instanceNameResolver;
52+
5153
Connector(
5254
ConnectorConfig config,
5355
ConnectionInfoRepositoryFactory connectionInfoRepositoryFactory,
@@ -56,7 +58,8 @@ class Connector {
5658
ListenableFuture<KeyPair> localKeyPair,
5759
long minRefreshDelayMs,
5860
long refreshTimeoutMs,
59-
int serverProxyPort) {
61+
int serverProxyPort,
62+
InstanceConnectionNameResolver instanceNameResolver) {
6063
this.config = config;
6164

6265
this.adminApi =
@@ -66,6 +69,7 @@ class Connector {
6669
this.localKeyPair = localKeyPair;
6770
this.minRefreshDelayMs = minRefreshDelayMs;
6871
this.serverProxyPort = serverProxyPort;
72+
this.instanceNameResolver = instanceNameResolver;
6973
}
7074

7175
public ConnectorConfig getConfig() {
@@ -139,9 +143,20 @@ Socket connect(ConnectionConfig config, long timeoutMs) throws IOException {
139143
}
140144
}
141145

142-
ConnectionInfoCache getConnection(ConnectionConfig config) {
146+
ConnectionInfoCache getConnection(final ConnectionConfig config) {
147+
CloudSqlInstanceName name = null;
148+
try {
149+
name = instanceNameResolver.resolve(config.getCloudSqlInstance());
150+
} catch (IllegalArgumentException e) {
151+
throw new IllegalArgumentException(
152+
String.format(
153+
"Cloud SQL connection name is invalid: \"%s\"", config.getCloudSqlInstance()),
154+
e);
155+
}
156+
final ConnectionConfig updatedConfig = config.withCloudSqlInstance(name.getConnectionName());
157+
143158
ConnectionInfoCache instance =
144-
instances.computeIfAbsent(config, k -> createConnectionInfo(config));
159+
instances.computeIfAbsent(updatedConfig, k -> createConnectionInfo(updatedConfig));
145160

146161
// If the client certificate has expired (as when the computer goes to
147162
// sleep, and the refresh cycle cannot run), force a refresh immediately.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2024 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 SRV 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+
// Attempt to parse the instance name
42+
try {
43+
return new CloudSqlInstanceName(name);
44+
} catch (IllegalArgumentException e) {
45+
// Not a well-formed instance name.
46+
}
47+
48+
// Next, attempt to resolve DNS name.
49+
Collection<DnsSrvRecord> instanceNames;
50+
try {
51+
instanceNames = this.dnsResolver.resolveSrv(name);
52+
} catch (NameNotFoundException ne) {
53+
// No DNS record found. This is not a valid instance name.
54+
throw new IllegalArgumentException(
55+
String.format("Unable to resolve SRV record for \"%s\".", name));
56+
}
57+
58+
// Use the first valid instance name from the list
59+
// or throw an IllegalArgumentException if none of the values can be parsed.
60+
return instanceNames.stream()
61+
.map(
62+
r -> {
63+
String target = r.getTarget();
64+
// Trim trailing '.' from target field
65+
if (target.endsWith(".")) {
66+
target = target.substring(0, target.length() - 1);
67+
}
68+
try {
69+
return new CloudSqlInstanceName(target);
70+
} catch (IllegalArgumentException e) {
71+
logger.info(
72+
"Unable to parse instance name in SRV record for "
73+
+ "domain name \"{}\" with target \"{}\"",
74+
name,
75+
target,
76+
e);
77+
return null;
78+
}
79+
})
80+
.filter(Objects::nonNull)
81+
.findFirst()
82+
.orElseThrow(
83+
() ->
84+
new IllegalArgumentException(
85+
String.format("Unable to parse values of SRV record for \"%s\".", name)));
86+
}
87+
}

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

+2-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 {
23-
Collection<DnsSrvRecord> resolveSrv(String domainName) throws NameNotFoundException;
24+
Collection<DnsSrvRecord> resolveSrv(String name) throws NameNotFoundException;
2425
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright 2024 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

+2-1
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,8 @@ private Connector createConnector(ConnectorConfig config) {
332332
localKeyPair,
333333
MIN_REFRESH_DELAY_MS,
334334
connectTimeoutMs,
335-
serverProxyPort);
335+
serverProxyPort,
336+
new DnsInstanceConnectionNameResolver(new JndiDnsResolver()));
336337
}
337338

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

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

+86-9
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
import java.nio.file.Path;
3535
import java.time.Duration;
3636
import java.time.Instant;
37+
import java.util.Collection;
3738
import java.util.Collections;
39+
import javax.naming.NameNotFoundException;
3840
import javax.net.ssl.SSLHandshakeException;
3941
import org.junit.After;
4042
import org.junit.Before;
@@ -147,6 +149,57 @@ public void create_successfulPublicConnection() throws IOException, InterruptedE
147149
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
148150
}
149151

152+
@Test
153+
public void create_successfulPublicConnectionWithDomainName()
154+
throws IOException, InterruptedException {
155+
FakeSslServer sslServer = new FakeSslServer();
156+
ConnectionConfig config =
157+
new ConnectionConfig.Builder()
158+
.withCloudSqlInstance("db.example.com")
159+
.withIpTypes("PRIMARY")
160+
.build();
161+
162+
int port = sslServer.start(PUBLIC_IP);
163+
164+
Connector connector = newConnector(config.getConnectorConfig(), port);
165+
166+
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
167+
168+
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
169+
}
170+
171+
@Test
172+
public void create_throwsErrorForUnresolvedDomainName() throws IOException {
173+
ConnectionConfig config =
174+
new ConnectionConfig.Builder()
175+
.withCloudSqlInstance("baddomain.example.com")
176+
.withIpTypes("PRIMARY")
177+
.build();
178+
Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT);
179+
RuntimeException ex =
180+
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
181+
182+
assertThat(ex)
183+
.hasMessageThat()
184+
.contains("Cloud SQL connection name is invalid: \"baddomain.example.com\"");
185+
}
186+
187+
@Test
188+
public void create_throwsErrorForDomainNameBadTargetValue() throws IOException {
189+
ConnectionConfig config =
190+
new ConnectionConfig.Builder()
191+
.withCloudSqlInstance("badvalue.example.com")
192+
.withIpTypes("PRIMARY")
193+
.build();
194+
Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT);
195+
RuntimeException ex =
196+
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
197+
198+
assertThat(ex)
199+
.hasMessageThat()
200+
.contains("Cloud SQL connection name is invalid: \"badvalue.example.com\"");
201+
}
202+
150203
private boolean isWindows() {
151204
String os = System.getProperty("os.name").toLowerCase();
152205
return os.contains("win");
@@ -210,7 +263,8 @@ public void create_successfulDomainScopedConnection() throws IOException, Interr
210263
clientKeyPair,
211264
10,
212265
TEST_MAX_REFRESH_MS,
213-
port);
266+
port,
267+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
214268

215269
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
216270

@@ -271,7 +325,8 @@ public void create_throwsException_adminApiNotEnabled() throws IOException {
271325
clientKeyPair,
272326
10,
273327
TEST_MAX_REFRESH_MS,
274-
DEFAULT_SERVER_PROXY_PORT);
328+
DEFAULT_SERVER_PROXY_PORT,
329+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
275330

276331
// Use a different project to get Api Not Enabled Error.
277332
TerminalException ex =
@@ -303,7 +358,8 @@ public void create_throwsException_adminApiReturnsNotAuthorized() throws IOExcep
303358
clientKeyPair,
304359
10,
305360
TEST_MAX_REFRESH_MS,
306-
DEFAULT_SERVER_PROXY_PORT);
361+
DEFAULT_SERVER_PROXY_PORT,
362+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
307363

308364
// Use a different instance to simulate incorrect permissions.
309365
TerminalException ex =
@@ -335,7 +391,8 @@ public void create_throwsException_badGateway() throws IOException {
335391
clientKeyPair,
336392
10,
337393
TEST_MAX_REFRESH_MS,
338-
DEFAULT_SERVER_PROXY_PORT);
394+
DEFAULT_SERVER_PROXY_PORT,
395+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
339396

340397
// If the gateway is down, then this is a temporary error, not a fatal error.
341398
RuntimeException ex =
@@ -377,7 +434,8 @@ public void create_successfulPublicConnection_withIntermittentBadGatewayErrors()
377434
clientKeyPair,
378435
10,
379436
TEST_MAX_REFRESH_MS,
380-
port);
437+
port,
438+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
381439

382440
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
383441

@@ -410,7 +468,8 @@ public void supportsCustomCredentialFactoryWithIAM() throws InterruptedException
410468
clientKeyPair,
411469
10,
412470
TEST_MAX_REFRESH_MS,
413-
port);
471+
port,
472+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
414473

415474
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
416475

@@ -442,7 +501,8 @@ public void supportsCustomCredentialFactoryWithNoExpirationTime()
442501
clientKeyPair,
443502
10,
444503
TEST_MAX_REFRESH_MS,
445-
port);
504+
port,
505+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
446506

447507
Socket socket = c.connect(config, TEST_MAX_REFRESH_MS);
448508

@@ -480,7 +540,8 @@ public HttpRequestInitializer create() {
480540
clientKeyPair,
481541
10,
482542
TEST_MAX_REFRESH_MS,
483-
DEFAULT_SERVER_PROXY_PORT);
543+
DEFAULT_SERVER_PROXY_PORT,
544+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
484545

485546
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
486547
}
@@ -497,7 +558,8 @@ private Connector newConnector(ConnectorConfig config, int port) {
497558
clientKeyPair,
498559
10,
499560
TEST_MAX_REFRESH_MS,
500-
port);
561+
port,
562+
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
501563
return connector;
502564
}
503565

@@ -506,4 +568,19 @@ private String readLine(Socket socket) throws IOException {
506568
new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
507569
return bufferedReader.readLine();
508570
}
571+
572+
private static class MockDnsResolver implements DnsResolver {
573+
574+
@Override
575+
public Collection<DnsSrvRecord> resolveSrv(String domainName) throws NameNotFoundException {
576+
if ("db.example.com".equals(domainName)) {
577+
return Collections.singletonList(
578+
new DnsSrvRecord("0 10 3307 myProject:myRegion:myInstance."));
579+
}
580+
if ("badvalue.example.com".equals(domainName)) {
581+
return Collections.singletonList(new DnsSrvRecord("0 10 3307 not-an-instance-name."));
582+
}
583+
throw new NameNotFoundException("Not found: " + domainName);
584+
}
585+
}
509586
}

core/src/test/java/com/google/cloud/sql/core/DnsSrvRecordTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* you may not use this file except in compliance with the License.
66
* You may obtain a copy of the License at
77
*
8-
* http://www.apache.org/licenses/LICENSE-2.0
8+
* http://www.apache.org/licenses/LICENSE-2.0
99
*
1010
* Unless required by applicable law or agreed to in writing, software
1111
* distributed under the License is distributed on an "AS IS" BASIS,

0 commit comments

Comments
 (0)