Skip to content

Commit

Permalink
feat: Automatically configure connections using DNS. Part of #2043.
Browse files Browse the repository at this point in the history
  • Loading branch information
hessjcg committed Jul 19, 2024
1 parent cb75da8 commit 46e95c4
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 15 deletions.
12 changes: 12 additions & 0 deletions core/src/main/java/com/google/cloud/sql/core/ConnectionConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,18 @@ public ConnectionConfig withConnectorConfig(ConnectorConfig config) {
config);
}

/** Creates a new instance of the ConnectionConfig with an updated cloudSqlInstance. */
public ConnectionConfig withCloudSqlInstance(String newCloudSqlInstance) {
return new ConnectionConfig(
newCloudSqlInstance,
namedConnector,
unixSocketPath,
ipTypes,
authType,
unixSocketPathSuffix,
connectorConfig);
}

public String getNamedConnector() {
return namedConnector;
}
Expand Down
21 changes: 18 additions & 3 deletions core/src/main/java/com/google/cloud/sql/core/Connector.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class Connector {
private final int serverProxyPort;
private final ConnectorConfig config;

private final InstanceConnectionNameResolver instanceNameResolver;

Connector(
ConnectorConfig config,
ConnectionInfoRepositoryFactory connectionInfoRepositoryFactory,
Expand All @@ -56,7 +58,8 @@ class Connector {
ListenableFuture<KeyPair> localKeyPair,
long minRefreshDelayMs,
long refreshTimeoutMs,
int serverProxyPort) {
int serverProxyPort,
InstanceConnectionNameResolver instanceNameResolver) {
this.config = config;

this.adminApi =
Expand All @@ -66,6 +69,7 @@ class Connector {
this.localKeyPair = localKeyPair;
this.minRefreshDelayMs = minRefreshDelayMs;
this.serverProxyPort = serverProxyPort;
this.instanceNameResolver = instanceNameResolver;
}

public ConnectorConfig getConfig() {
Expand Down Expand Up @@ -139,9 +143,20 @@ Socket connect(ConnectionConfig config, long timeoutMs) throws IOException {
}
}

ConnectionInfoCache getConnection(ConnectionConfig config) {
ConnectionInfoCache getConnection(final ConnectionConfig config) {
CloudSqlInstanceName name = null;
try {
name = instanceNameResolver.resolve(config.getCloudSqlInstance());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
String.format(
"Cloud SQL connection name is invalid: \"%s\"", config.getCloudSqlInstance()),
e);
}
final ConnectionConfig updatedConfig = config.withCloudSqlInstance(name.getConnectionName());

ConnectionInfoCache instance =
instances.computeIfAbsent(config, k -> createConnectionInfo(config));
instances.computeIfAbsent(updatedConfig, k -> createConnectionInfo(updatedConfig));

// If the client certificate has expired (as when the computer goes to
// sleep, and the refresh cycle cannot run), force a refresh immediately.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.cloud.sql.core;

import java.util.Collection;
import java.util.Objects;
import javax.naming.NameNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* An implementation of InstanceConnectionNameResolver that uses DNS SRV records to resolve an
* instance name from a domain name.
*/
class DnsInstanceConnectionNameResolver implements InstanceConnectionNameResolver {
private static final Logger logger =
LoggerFactory.getLogger(DnsInstanceConnectionNameResolver.class);

private final DnsResolver dnsResolver;

public DnsInstanceConnectionNameResolver(DnsResolver dnsResolver) {
this.dnsResolver = dnsResolver;
}

@Override
public CloudSqlInstanceName resolve(final String name) {
// Attempt to parse the instance name
try {
return new CloudSqlInstanceName(name);
} catch (IllegalArgumentException e) {
// Not a well-formed instance name.
}

// Next, attempt to resolve DNS name.
Collection<DnsSrvRecord> instanceNames;
try {
instanceNames = this.dnsResolver.resolveSrv(name);
} catch (NameNotFoundException ne) {
// No DNS record found. This is not a valid instance name.
throw new IllegalArgumentException(
String.format("Unable to resolve SRV record for \"%s\".", name));
}

// Use the first valid instance name from the list
// or throw an IllegalArgumentException if none of the values can be parsed.
return instanceNames.stream()
.map(
r -> {
String target = r.getTarget();
// Trim trailing '.' from target field
if (target.endsWith(".")) {
target = target.substring(0, target.length() - 1);
}
try {
return new CloudSqlInstanceName(target);
} catch (IllegalArgumentException e) {
logger.info(
"Unable to parse instance name in SRV record for "
+ "domain name \"{}\" with target \"{}\"",
name,
target,
e);
return null;
}
})
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(
() ->
new IllegalArgumentException(
String.format("Unable to parse values of SRV record for \"%s\".", name)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.Collection;
import javax.naming.NameNotFoundException;

/** Wraps the Java DNS API. */
interface DnsResolver {
Collection<DnsSrvRecord> resolveSrv(String domainName) throws NameNotFoundException;
Collection<DnsSrvRecord> resolveSrv(String name) throws NameNotFoundException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.cloud.sql.core;

/** Resolves the Cloud SQL Instance from the configuration name. */
interface InstanceConnectionNameResolver {

/**
* Resolves the CloudSqlInstanceName from a configuration string value.
*
* @param name the configuration string
* @return the CloudSqlInstanceName
* @throws IllegalArgumentException if the name cannot be resolved.
*/
CloudSqlInstanceName resolve(String name);
}
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,8 @@ private Connector createConnector(ConnectorConfig config) {
localKeyPair,
MIN_REFRESH_DELAY_MS,
connectTimeoutMs,
serverProxyPort);
serverProxyPort,
new DnsInstanceConnectionNameResolver(new JndiDnsResolver()));
}

/** Register the configuration for a named connector. */
Expand Down
95 changes: 86 additions & 9 deletions core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import javax.naming.NameNotFoundException;
import javax.net.ssl.SSLHandshakeException;
import org.junit.After;
import org.junit.Before;
Expand Down Expand Up @@ -147,6 +149,57 @@ public void create_successfulPublicConnection() throws IOException, InterruptedE
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
}

@Test
public void create_successfulPublicConnectionWithDomainName()
throws IOException, InterruptedException {
FakeSslServer sslServer = new FakeSslServer();
ConnectionConfig config =
new ConnectionConfig.Builder()
.withCloudSqlInstance("db.example.com")
.withIpTypes("PRIMARY")
.build();

int port = sslServer.start(PUBLIC_IP);

Connector connector = newConnector(config.getConnectorConfig(), port);

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

assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
}

@Test
public void create_throwsErrorForUnresolvedDomainName() throws IOException {
ConnectionConfig config =
new ConnectionConfig.Builder()
.withCloudSqlInstance("baddomain.example.com")
.withIpTypes("PRIMARY")
.build();
Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT);
RuntimeException ex =
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));

assertThat(ex)
.hasMessageThat()
.contains("Cloud SQL connection name is invalid: \"baddomain.example.com\"");
}

@Test
public void create_throwsErrorForDomainNameBadTargetValue() throws IOException {
ConnectionConfig config =
new ConnectionConfig.Builder()
.withCloudSqlInstance("badvalue.example.com")
.withIpTypes("PRIMARY")
.build();
Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT);
RuntimeException ex =
assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));

assertThat(ex)
.hasMessageThat()
.contains("Cloud SQL connection name is invalid: \"badvalue.example.com\"");
}

private boolean isWindows() {
String os = System.getProperty("os.name").toLowerCase();
return os.contains("win");
Expand Down Expand Up @@ -210,7 +263,8 @@ public void create_successfulDomainScopedConnection() throws IOException, Interr
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
port);
port,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

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

Expand Down Expand Up @@ -271,7 +325,8 @@ public void create_throwsException_adminApiNotEnabled() throws IOException {
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
DEFAULT_SERVER_PROXY_PORT);
DEFAULT_SERVER_PROXY_PORT,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

// Use a different project to get Api Not Enabled Error.
TerminalException ex =
Expand Down Expand Up @@ -303,7 +358,8 @@ public void create_throwsException_adminApiReturnsNotAuthorized() throws IOExcep
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
DEFAULT_SERVER_PROXY_PORT);
DEFAULT_SERVER_PROXY_PORT,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

// Use a different instance to simulate incorrect permissions.
TerminalException ex =
Expand Down Expand Up @@ -335,7 +391,8 @@ public void create_throwsException_badGateway() throws IOException {
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
DEFAULT_SERVER_PROXY_PORT);
DEFAULT_SERVER_PROXY_PORT,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

// If the gateway is down, then this is a temporary error, not a fatal error.
RuntimeException ex =
Expand Down Expand Up @@ -377,7 +434,8 @@ public void create_successfulPublicConnection_withIntermittentBadGatewayErrors()
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
port);
port,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

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

Expand Down Expand Up @@ -410,7 +468,8 @@ public void supportsCustomCredentialFactoryWithIAM() throws InterruptedException
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
port);
port,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

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

Expand Down Expand Up @@ -442,7 +501,8 @@ public void supportsCustomCredentialFactoryWithNoExpirationTime()
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
port);
port,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

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

Expand Down Expand Up @@ -480,7 +540,8 @@ public HttpRequestInitializer create() {
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
DEFAULT_SERVER_PROXY_PORT);
DEFAULT_SERVER_PROXY_PORT,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));

assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS));
}
Expand All @@ -497,7 +558,8 @@ private Connector newConnector(ConnectorConfig config, int port) {
clientKeyPair,
10,
TEST_MAX_REFRESH_MS,
port);
port,
new DnsInstanceConnectionNameResolver(new MockDnsResolver()));
return connector;
}

Expand All @@ -506,4 +568,19 @@ private String readLine(Socket socket) throws IOException {
new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
return bufferedReader.readLine();
}

private static class MockDnsResolver implements DnsResolver {

@Override
public Collection<DnsSrvRecord> resolveSrv(String domainName) throws NameNotFoundException {
if ("db.example.com".equals(domainName)) {
return Collections.singletonList(
new DnsSrvRecord("0 10 3307 myProject:myRegion:myInstance."));
}
if ("badvalue.example.com".equals(domainName)) {
return Collections.singletonList(new DnsSrvRecord("0 10 3307 not-an-instance-name."));
}
throw new NameNotFoundException("Not found: " + domainName);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
Expand Down

0 comments on commit 46e95c4

Please sign in to comment.