From b01609572ab4f0dba6eae23fe5be05a302ecefc7 Mon Sep 17 00:00:00 2001 From: Carl Mastrangelo Date: Mon, 22 Jan 2018 16:16:52 -0800 Subject: [PATCH] core: handle long dns txt records properly, parse service config, and add tests --- .../io/grpc/internal/DnsNameResolver.java | 172 ++++++++++++++- .../io/grpc/internal/DnsNameResolverTest.java | 205 +++++++++++++++++- 2 files changed, 367 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/io/grpc/internal/DnsNameResolver.java b/core/src/main/java/io/grpc/internal/DnsNameResolver.java index 4d028c3b2b..aa32abd4b3 100644 --- a/core/src/main/java/io/grpc/internal/DnsNameResolver.java +++ b/core/src/main/java/io/grpc/internal/DnsNameResolver.java @@ -21,6 +21,11 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Verify; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; import io.grpc.Attributes; import io.grpc.EquivalentAddressGroup; import io.grpc.NameResolver; @@ -34,7 +39,12 @@ import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Map.Entry; +import java.util.Random; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -62,14 +72,21 @@ final class DnsNameResolver extends NameResolver { private static final Logger logger = Logger.getLogger(DnsNameResolver.class.getName()); private static final boolean JNDI_AVAILABLE = jndiAvailable(); + private static final String LOCAL_HOST_NAME = getHostName(); // From https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md private static final String SERVICE_CONFIG_NAME_PREFIX = "_grpc_config."; // From https://github.com/grpc/proposal/blob/master/A5-grpclb-in-dns.md private static final String GRPCLB_NAME_PREFIX = "_grpclb._tcp."; + // From https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md + private static final String SERVICE_CONFIG_PREFIX = "_grpc_config="; + private static final Set SERVICE_CONFIG_CHOICE_KEYS = + Collections.unmodifiableSet( + new HashSet( + Arrays.asList("clientLanguage", "percentage", "clientHostname", "serviceConfig"))); private static final String JNDI_PROPERTY = - System.getProperty("io.grpc.internal.DnsNameResolverProvider.enable_jndi", "false"); + System.getProperty("io.grpc.internal.DnsNameResolverProvider.enable_jndi", "true"); @VisibleForTesting static boolean enableJndi = Boolean.parseBoolean(JNDI_PROPERTY); @@ -95,6 +112,8 @@ final class DnsNameResolver extends NameResolver { @GuardedBy("this") private Listener listener; + private final Random random = new Random(); + DnsNameResolver(@Nullable String nsAuthority, String name, Attributes params, Resource timerServiceResource, Resource executorResource, @@ -194,13 +213,29 @@ final class DnsNameResolver extends NameResolver { Attributes.Builder attrs = Attributes.newBuilder(); if (!resolvedInetAddrs.txtRecords.isEmpty()) { - // TODO(carl-mastrangelo): re enable this - /* - attrs.set( - GrpcAttributes.NAME_RESOLVER_ATTR_SERVICE_CONFIG, - Collections.unmodifiableList(new ArrayList(resolvedInetAddrs.txtRecords))); - */ + JsonObject serviceConfig = null; + try { + JsonArray allServiceConfigChoices = parseTxtResults(resolvedInetAddrs.txtRecords); + for (JsonElement serviceConfigChoice : allServiceConfigChoices) { + try { + serviceConfig = maybeChooseServiceConfig( + serviceConfigChoice.getAsJsonObject(), random, LOCAL_HOST_NAME); + if (serviceConfig != null) { + break; + } + } catch (RuntimeException e) { + logger.log(Level.WARNING, "Bad service config choice " + serviceConfigChoice, e); + } + } + if (serviceConfig != null) { + attrs.set(GrpcAttributes.NAME_RESOLVER_ATTR_SERVICE_CONFIG, serviceConfig); + } + } catch (RuntimeException e) { + logger.log(Level.WARNING, "Can't parse service Configs", e); + } + } else { + logger.log(Level.FINE, "No TXT records found for {0}", new Object[]{host}); } savedListener.onAddresses(servers, attrs.build()); } finally { @@ -387,7 +422,6 @@ final class DnsNameResolver extends NameResolver { try { serviceConfigTxtRecords = getAllRecords("TXT", "dns:///" + serviceConfigHostname); } catch (NamingException e) { - if (logger.isLoggable(Level.FINE)) { logger.log(Level.FINE, "Unable to look up " + serviceConfigHostname, e); } @@ -451,7 +485,7 @@ final class DnsNameResolver extends NameResolver { NamingEnumeration rrValues = rrEntry.getAll(); try { while (rrValues.hasMore()) { - records.add(String.valueOf(rrValues.next())); + records.add(unquote(String.valueOf(rrValues.next()))); } } finally { rrValues.close(); @@ -463,4 +497,124 @@ final class DnsNameResolver extends NameResolver { return records; } } + + @VisibleForTesting + static JsonArray parseTxtResults(List txtRecords) { + JsonArray serviceConfigs = new JsonArray(); + Gson gson = new Gson(); + + for (String txtRecord : txtRecords) { + if (txtRecord.startsWith(SERVICE_CONFIG_PREFIX)) { + JsonArray choices; + try { + choices = + gson.fromJson(txtRecord.substring(SERVICE_CONFIG_PREFIX.length()), JsonArray.class); + } catch (JsonSyntaxException e) { + logger.log(Level.WARNING, "Bad service config: " + txtRecord, e); + continue; + } + serviceConfigs.addAll(choices); + } else { + logger.log(Level.FINE, "Ignoring non service config {0}", new Object[]{txtRecord}); + } + } + + return serviceConfigs; + } + + /** + * Determines if a given Service Config choice applies, and if so, returns it. + * + * @see + * Service Config in DNS + * @param choice The service config choice. + * @return The service config object or {@code null} if this choice does not apply. + */ + @VisibleForTesting + @Nullable + @SuppressWarnings("BetaApi") // Verify isn't all that beta + static JsonObject maybeChooseServiceConfig(JsonObject choice, Random random, String hostname) { + for (Entry entry : choice.entrySet()) { + Verify.verify(SERVICE_CONFIG_CHOICE_KEYS.contains(entry.getKey()), "Bad key: %s", entry); + } + if (choice.has("clientLanguage")) { + JsonArray clientLanguages = choice.get("clientLanguage").getAsJsonArray(); + if (clientLanguages.size() != 0) { + boolean javaPresent = false; + for (JsonElement clientLanguage : clientLanguages) { + String lang = clientLanguage.getAsString().toLowerCase(Locale.ROOT); + if ("java".equals(lang)) { + javaPresent = true; + break; + } + } + if (!javaPresent) { + return null; + } + } + } + if (choice.has("percentage")) { + int pct = choice.get("percentage").getAsInt(); + Verify.verify(pct >= 0 && pct <= 100, "Bad percentage", choice.get("percentage")); + if (pct == 0) { + return null; + } else if (pct != 100 && pct < random.nextInt(100)) { + return null; + } + } + if (choice.has("clientHostname")) { + JsonArray clientHostnames = choice.get("clientHostname").getAsJsonArray(); + if (clientHostnames.size() != 0) { + boolean hostnamePresent = false; + for (JsonElement clientHostname : clientHostnames) { + if (clientHostname.getAsString().equals(hostname)) { + hostnamePresent = true; + break; + } + } + if (!hostnamePresent) { + return null; + } + } + } + return choice.getAsJsonObject("serviceConfig"); + } + + /** + * Undo the quoting done in {@link com.sun.jndi.dns.ResourceRecord#decodeTxt}. + */ + @VisibleForTesting + static String unquote(String txtRecord) { + StringBuilder sb = new StringBuilder(txtRecord.length()); + boolean inquote = false; + for (int i = 0; i < txtRecord.length(); i++) { + char c = txtRecord.charAt(i); + if (!inquote) { + if (c == ' ') { + continue; + } else if (c == '"') { + inquote = true; + continue; + } + } else { + if (c == '"') { + inquote = false; + continue; + } else if (c == '\\') { + c = txtRecord.charAt(++i); + assert c == '"' || c == '\\'; + } + } + sb.append(c); + } + return sb.toString(); + } + + private static final String getHostName() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } } diff --git a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java index 7183217e3b..f7717b3d60 100644 --- a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java +++ b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java @@ -19,6 +19,7 @@ package io.grpc.internal; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -32,6 +33,9 @@ import static org.mockito.Mockito.when; import com.google.common.base.MoreObjects; import com.google.common.collect.Iterables; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import io.grpc.Attributes; import io.grpc.EquivalentAddressGroup; import io.grpc.NameResolver; @@ -50,6 +54,7 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Queue; +import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -58,6 +63,7 @@ import org.junit.Assume; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import org.junit.rules.Timeout; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -70,7 +76,8 @@ import org.mockito.MockitoAnnotations; @RunWith(JUnit4.class) public class DnsNameResolverTest { - @Rule public final Timeout globalTimeout = Timeout.seconds(10); + @Rule public final Timeout globalTimeout = Timeout.seconds(100000); + @Rule public final ExpectedException thrown = ExpectedException.none(); private static final int DEFAULT_PORT = 887; private static final Attributes NAME_RESOLVER_PARAMS = @@ -385,6 +392,202 @@ public class DnsNameResolverTest { assertTrue(((InetSocketAddress) socketAddress).isUnresolved()); } + @Test + public void unquoteRemovesJndiFormatting() { + assertEquals("blah", DnsNameResolver.unquote("blah")); + assertEquals("", DnsNameResolver.unquote("\"\"")); + assertEquals("blahblah", DnsNameResolver.unquote("blah blah")); + assertEquals("blahfoo blah", DnsNameResolver.unquote("blah \"foo blah\"")); + assertEquals("blah blah", DnsNameResolver.unquote("\"blah blah\"")); + assertEquals("blah\"blah", DnsNameResolver.unquote("\"blah\\\"blah\"")); + assertEquals("blah\\blah", DnsNameResolver.unquote("\"blah\\\\blah\"")); + } + + @Test + public void maybeChooseServiceConfig_failsOnMisspelling() { + JsonObject bad = new JsonObject(); + bad.add("parcentage", new JsonPrimitive(1)); + thrown.expectMessage("Bad key"); + + DnsNameResolver.maybeChooseServiceConfig(bad, new Random(), "host"); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageMatchesJava() { + JsonObject choice = new JsonObject(); + JsonArray langs = new JsonArray(); + langs.add("java"); + choice.add("clientLanguage", langs); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageDoesntMatchGo() { + JsonObject choice = new JsonObject(); + JsonArray langs = new JsonArray(); + langs.add("go"); + choice.add("clientLanguage", langs); + choice.add("serviceConfig", new JsonObject()); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageCaseInsensitive() { + JsonObject choice = new JsonObject(); + JsonArray langs = new JsonArray(); + langs.add("JAVA"); + choice.add("clientLanguage", langs); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageMatchesEmtpy() { + JsonObject choice = new JsonObject(); + JsonArray langs = new JsonArray(); + choice.add("clientLanguage", langs); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageMatchesMulti() { + JsonObject choice = new JsonObject(); + JsonArray langs = new JsonArray(); + langs.add("go"); + langs.add("java"); + choice.add("clientLanguage", langs); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageZeroAlwaysFails() { + JsonObject choice = new JsonObject(); + choice.add("percentage", new JsonPrimitive(0)); + choice.add("serviceConfig", new JsonObject()); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageHundredAlwaysSucceeds() { + JsonObject choice = new JsonObject(); + choice.add("percentage", new JsonPrimitive(100)); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAboveMatches() { + JsonObject choice = new JsonObject(); + choice.add("percentage", new JsonPrimitive(50)); + choice.add("serviceConfig", new JsonObject()); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 49; + } + }; + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageAtMatches() { + JsonObject choice = new JsonObject(); + choice.add("percentage", new JsonPrimitive(50)); + choice.add("serviceConfig", new JsonObject()); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 50; + } + }; + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_percentageBelowFails() { + JsonObject choice = new JsonObject(); + choice.add("percentage", new JsonPrimitive(50)); + choice.add("serviceConfig", new JsonObject()); + + Random r = new Random() { + @Override + public int nextInt(int bound) { + return 51; + } + }; + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host")); + } + + @Test + public void maybeChooseServiceConfig_hostnameMatches() { + JsonObject choice = new JsonObject(); + JsonArray hosts = new JsonArray(); + hosts.add("localhost"); + choice.add("clientHostname", hosts); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + + @Test + public void maybeChooseServiceConfig_hostnameDoesntMatch() { + JsonObject choice = new JsonObject(); + JsonArray hosts = new JsonArray(); + hosts.add("localhorse"); + choice.add("clientHostname", hosts); + choice.add("serviceConfig", new JsonObject()); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + + @Test + public void maybeChooseServiceConfig_clientLanguageCaseSensitive() { + JsonObject choice = new JsonObject(); + JsonArray hosts = new JsonArray(); + hosts.add("LOCALHOST"); + choice.add("clientHostname", hosts); + choice.add("serviceConfig", new JsonObject()); + + assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + + @Test + public void maybeChooseServiceConfig_hostnameMatchesEmtpy() { + JsonObject choice = new JsonObject(); + JsonArray hosts = new JsonArray(); + choice.add("clientHostname", hosts); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host")); + } + + @Test + public void maybeChooseServiceConfig_hostnameMatchesMulti() { + JsonObject choice = new JsonObject(); + JsonArray langs = new JsonArray(); + langs.add("localhorse"); + langs.add("localhost"); + choice.add("clientHostname", langs); + choice.add("serviceConfig", new JsonObject()); + + assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost")); + } + private void testInvalidUri(URI uri) { try { provider.newNameResolver(uri, NAME_RESOLVER_PARAMS);