From 4ad47dfce1bce89adbc7f2233ba534b09ebac4fb Mon Sep 17 00:00:00 2001 From: Jonathan Hess Date: Thu, 18 Jul 2024 10:05:02 -0600 Subject: [PATCH] feat: Automatically configure connections using DNS. Part of #2043. --- .../com/google/cloud/sql/core/Connector.java | 17 ++- .../DnsInstanceConnectionNameResolver.java | 82 +++++++++++ .../google/cloud/sql/core/DnsResolver.java | 1 + .../core/InstanceConnectionNameResolver.java | 30 ++++ .../sql/core/InternalConnectorRegistry.java | 3 +- .../google/cloud/sql/core/ConnectorTest.java | 133 ++++++++++++------ 6 files changed, 218 insertions(+), 48 deletions(-) create mode 100644 core/src/main/java/com/google/cloud/sql/core/DnsInstanceConnectionNameResolver.java create mode 100644 core/src/main/java/com/google/cloud/sql/core/InstanceConnectionNameResolver.java diff --git a/core/src/main/java/com/google/cloud/sql/core/Connector.java b/core/src/main/java/com/google/cloud/sql/core/Connector.java index 7d75ee63b..9d19d994e 100644 --- a/core/src/main/java/com/google/cloud/sql/core/Connector.java +++ b/core/src/main/java/com/google/cloud/sql/core/Connector.java @@ -49,6 +49,8 @@ class Connector { private final int serverProxyPort; private final ConnectorConfig config; + private final InstanceConnectionNameResolver instanceNameResolver; + Connector( ConnectorConfig config, ConnectionInfoRepositoryFactory connectionInfoRepositoryFactory, @@ -57,7 +59,8 @@ class Connector { ListenableFuture localKeyPair, long minRefreshDelayMs, long refreshTimeoutMs, - int serverProxyPort) { + int serverProxyPort, + InstanceConnectionNameResolver instanceNameResolver) { this.config = config; this.adminApi = @@ -67,6 +70,7 @@ class Connector { this.localKeyPair = localKeyPair; this.minRefreshDelayMs = minRefreshDelayMs; this.serverProxyPort = serverProxyPort; + this.instanceNameResolver = instanceNameResolver; } public ConnectorConfig getConfig() { @@ -174,17 +178,16 @@ private ConnectionConfig resolveConnectionName(ConnectionConfig config) { final String unresolvedName = config.getDomainName(); final Function resolver = config.getConnectorConfig().getInstanceNameResolver(); + CloudSqlInstanceName name; if (resolver != null) { - return config.withCloudSqlInstance(resolver.apply(unresolvedName)); + name = instanceNameResolver.resolve(resolver.apply(unresolvedName)); } else { - throw new IllegalStateException( - "Can't resolve domain " + unresolvedName + ". ConnectorConfig.resolver is not set."); + name = instanceNameResolver.resolve(unresolvedName); } + return config.withCloudSqlInstance(name.getConnectionName()); } catch (IllegalArgumentException e) { throw new IllegalArgumentException( - String.format( - "Cloud SQL connection name is invalid: \"%s\"", config.getCloudSqlInstance()), - e); + String.format("Cloud SQL connection name is invalid: \"%s\"", config.getDomainName()), e); } } diff --git a/core/src/main/java/com/google/cloud/sql/core/DnsInstanceConnectionNameResolver.java b/core/src/main/java/com/google/cloud/sql/core/DnsInstanceConnectionNameResolver.java new file mode 100644 index 000000000..37c9f90ef --- /dev/null +++ b/core/src/main/java/com/google/cloud/sql/core/DnsInstanceConnectionNameResolver.java @@ -0,0 +1,82 @@ +/* + * 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 TXT 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 instanceNames; + try { + instanceNames = this.dnsResolver.resolveTxt(name); + } catch (NameNotFoundException ne) { + // No DNS record found. This is not a valid instance name. + throw new IllegalArgumentException( + String.format("Unable to resolve TXT 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( + target -> { + try { + return new CloudSqlInstanceName(target); + } catch (IllegalArgumentException e) { + logger.info( + "Unable to parse instance name in TXT 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 TXT record for \"%s\".", name))); + } +} diff --git a/core/src/main/java/com/google/cloud/sql/core/DnsResolver.java b/core/src/main/java/com/google/cloud/sql/core/DnsResolver.java index 4cc186783..65b85c939 100644 --- a/core/src/main/java/com/google/cloud/sql/core/DnsResolver.java +++ b/core/src/main/java/com/google/cloud/sql/core/DnsResolver.java @@ -19,6 +19,7 @@ import java.util.Collection; import javax.naming.NameNotFoundException; +/** Wraps the Java DNS API. */ interface DnsResolver { Collection resolveTxt(String domainName) throws NameNotFoundException; } diff --git a/core/src/main/java/com/google/cloud/sql/core/InstanceConnectionNameResolver.java b/core/src/main/java/com/google/cloud/sql/core/InstanceConnectionNameResolver.java new file mode 100644 index 000000000..6d854a985 --- /dev/null +++ b/core/src/main/java/com/google/cloud/sql/core/InstanceConnectionNameResolver.java @@ -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); +} diff --git a/core/src/main/java/com/google/cloud/sql/core/InternalConnectorRegistry.java b/core/src/main/java/com/google/cloud/sql/core/InternalConnectorRegistry.java index 68e61a699..e5390277b 100644 --- a/core/src/main/java/com/google/cloud/sql/core/InternalConnectorRegistry.java +++ b/core/src/main/java/com/google/cloud/sql/core/InternalConnectorRegistry.java @@ -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. */ diff --git a/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java b/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java index 9879bad04..f40785231 100644 --- a/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java +++ b/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java @@ -36,7 +36,9 @@ import java.security.cert.X509Certificate; 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; @@ -71,7 +73,7 @@ public void create_throwsErrorForInvalidInstanceName() throws IOException { .withIpTypes("PRIMARY") .build(); - Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT); + Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null); IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS)); @@ -96,7 +98,7 @@ public void create_throwsErrorForInvalidTlsCommonNameMismatch() int port = sslServer.start(PUBLIC_IP); - Connector connector = newConnector(config.getConnectorConfig(), port); + Connector connector = newConnector(config.getConnectorConfig(), port, null, null); SSLHandshakeException ex = assertThrows( SSLHandshakeException.class, () -> connector.connect(config, TEST_MAX_REFRESH_MS)); @@ -124,7 +126,7 @@ public void create_successfulPrivateConnection() throws IOException, Interrupted int port = sslServer.start(PRIVATE_IP); - Connector connector = newConnector(config.getConnectorConfig(), port); + Connector connector = newConnector(config.getConnectorConfig(), port, null, null); Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS); @@ -132,22 +134,17 @@ public void create_successfulPrivateConnection() throws IOException, Interrupted } @Test - public void create_successfulPublicConnectionWithDomainName() - throws IOException, InterruptedException { + public void create_successfulPublicConnection() throws IOException, InterruptedException { FakeSslServer sslServer = new FakeSslServer(); ConnectionConfig config = new ConnectionConfig.Builder() - .withDomainName("db.example.com") + .withCloudSqlInstance("myProject:myRegion:myInstance") .withIpTypes("PRIMARY") - .withConnectorConfig( - new ConnectorConfig.Builder() - .withInstanceNameResolver((domainName) -> "myProject:myRegion:myInstance") - .build()) .build(); int port = sslServer.start(PUBLIC_IP); - Connector connector = newConnector(config.getConnectorConfig(), port); + Connector connector = newConnector(config.getConnectorConfig(), port, null, null); Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS); @@ -155,9 +152,8 @@ public void create_successfulPublicConnectionWithDomainName() } @Test - public void create_throwsErrorForDomainNameWithNoResolver() + public void create_successfulPublicConnectionWithDomainName() throws IOException, InterruptedException { - // The server TLS certificate matches myProject:myRegion:myInstance FakeSslServer sslServer = new FakeSslServer(); ConnectionConfig config = new ConnectionConfig.Builder() @@ -167,30 +163,50 @@ public void create_throwsErrorForDomainNameWithNoResolver() int port = sslServer.start(PUBLIC_IP); - Connector connector = newConnector(config.getConnectorConfig(), port); - IllegalStateException ex = - assertThrows( - IllegalStateException.class, () -> connector.connect(config, TEST_MAX_REFRESH_MS)); + Connector connector = + newConnector( + config.getConnectorConfig(), port, "db.example.com", "myProject:myRegion:myInstance"); - assertThat(ex).hasMessageThat().contains("ConnectorConfig.resolver is not set"); + Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS); + + assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE); } @Test - public void create_successfulPublicConnection() throws IOException, InterruptedException { - FakeSslServer sslServer = new FakeSslServer(); + public void create_throwsErrorForUnresolvedDomainName() throws IOException { ConnectionConfig config = new ConnectionConfig.Builder() - .withCloudSqlInstance("myProject:myRegion:myInstance") + .withDomainName("baddomain.example.com") .withIpTypes("PRIMARY") .build(); + Connector c = + newConnector( + config.getConnectorConfig(), + DEFAULT_SERVER_PROXY_PORT, + "baddomain.example.com", + "invalid-name"); + RuntimeException ex = + assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS)); - int port = sslServer.start(PUBLIC_IP); - - Connector connector = newConnector(config.getConnectorConfig(), port); + assertThat(ex) + .hasMessageThat() + .contains("Cloud SQL connection name is invalid: \"baddomain.example.com\""); + } - Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS); + @Test + public void create_throwsErrorForDomainNameBadTargetValue() throws IOException { + ConnectionConfig config = + new ConnectionConfig.Builder() + .withDomainName("badvalue.example.com") + .withIpTypes("PRIMARY") + .build(); + Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null); + RuntimeException ex = + assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS)); - assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE); + assertThat(ex) + .hasMessageThat() + .contains("Cloud SQL connection name is invalid: \"badvalue.example.com\""); } @Test @@ -219,7 +235,8 @@ public void create_successfulPublicCasConnection() throws IOException, Interrupt clientKeyPair, 10, TEST_MAX_REFRESH_MS, - port); + port, + null); Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS); @@ -253,7 +270,7 @@ public void create_successfulUnixSocketConnection() throws IOException, Interrup unixSocketServer.start(); - Connector connector = newConnector(config.getConnectorConfig(), 10000); + Connector connector = newConnector(config.getConnectorConfig(), 10000, null, null); Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS); @@ -289,7 +306,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); @@ -303,7 +321,7 @@ public void create_throwsErrorForInvalidInstanceRegion() throws IOException { .withCloudSqlInstance("myProject:notMyRegion:myInstance") .withIpTypes("PRIMARY") .build(); - Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT); + Connector c = newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null); RuntimeException ex = assertThrows(RuntimeException.class, () -> c.connect(config, TEST_MAX_REFRESH_MS)); @@ -327,7 +345,7 @@ public void create_failOnEmptyTargetPrincipal() throws IOException, InterruptedE IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT)); + () -> newConnector(config.getConnectorConfig(), DEFAULT_SERVER_PROXY_PORT, null, null)); assertThat(ex.getMessage()).contains(ConnectionConfig.CLOUD_SQL_TARGET_PRINCIPAL_PROPERTY); } @@ -350,7 +368,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 = @@ -382,7 +401,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 = @@ -414,7 +434,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 = @@ -456,7 +477,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); @@ -489,7 +511,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); @@ -521,7 +544,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); @@ -559,12 +583,14 @@ 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)); } - private Connector newConnector(ConnectorConfig config, int port) { + private Connector newConnector( + ConnectorConfig config, int port, String domainName, String instanceName) { ConnectionInfoRepositoryFactory factory = new StubConnectionInfoRepositoryFactory(fakeSuccessHttpTransport(Duration.ofSeconds(0))); Connector connector = @@ -576,7 +602,8 @@ private Connector newConnector(ConnectorConfig config, int port) { clientKeyPair, 10, TEST_MAX_REFRESH_MS, - port); + port, + new DnsInstanceConnectionNameResolver(new MockDnsResolver(domainName, instanceName))); return connector; } @@ -585,4 +612,30 @@ private String readLine(Socket socket) throws IOException { new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8)); return bufferedReader.readLine(); } + + private static class MockDnsResolver implements DnsResolver { + private final String domainName; + private final String instanceName; + + public MockDnsResolver() { + this.domainName = null; + this.instanceName = null; + } + + public MockDnsResolver(String domainName, String instanceName) { + this.domainName = domainName; + this.instanceName = instanceName; + } + + @Override + public Collection resolveTxt(String domainName) throws NameNotFoundException { + if (this.domainName != null && this.domainName.equals(domainName)) { + return Collections.singletonList(this.instanceName); + } + if ("badvalue.example.com".equals(domainName)) { + return Collections.singletonList("not-an-instance-name"); + } + throw new NameNotFoundException("Not found: " + domainName); + } + } }