diff --git a/AUTHORS b/AUTHORS index bb7dd99ea..e09e1cbbe 100644 --- a/AUTHORS +++ b/AUTHORS @@ -7,3 +7,4 @@ # The email address is not required for organizations. Google Inc. +Donuts Inc. diff --git a/CONTRIBUTORS b/CONTRIBUTORS index d1b6e6a4f..1050e14e7 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -21,3 +21,4 @@ Jared Brothers Pablo Mayrgundter Daisuke Yabuki Tim Boring +Hans Ridder diff --git a/java/com/google/domain/registry/dns/writer/api/DnsWriter.java b/java/com/google/domain/registry/dns/writer/api/DnsWriter.java index 78765c80d..e37b25c45 100644 --- a/java/com/google/domain/registry/dns/writer/api/DnsWriter.java +++ b/java/com/google/domain/registry/dns/writer/api/DnsWriter.java @@ -30,15 +30,22 @@ public interface DnsWriter extends AutoCloseable { /** * Loads {@code domainName} from datastore and publishes its NS/DS records to the DNS server. + * Replaces existing records for the exact name supplied with an NS record for each name server + * and a DS record for each delegation signer stored in the registry for the supplied domain name. + * If the domain is deleted or is in a "non-publish" state then any existing records are deleted. * - * @param domainName the fully qualified domain name + * @param domainName the fully qualified domain name, with no trailing dot */ void publishDomain(String domainName); /** * Loads {@code hostName} from datastore and publishes its A/AAAA glue records to the DNS server. + * Replaces existing records for the exact name supplied, with an A or AAAA record (as + * appropriate) for each address stored in the registry, for the supplied host name. If the host is + * deleted then the existing records are deleted. Assumes that this method will only be called for + * in-bailiwick hosts. The registry does not have addresses for other hosts. * - * @param hostName the fully qualified host name + * @param hostName the fully qualified host name, with no trailing dot */ void publishHost(String hostName); diff --git a/java/com/google/domain/registry/dns/writer/dnsupdate/BUILD b/java/com/google/domain/registry/dns/writer/dnsupdate/BUILD new file mode 100644 index 000000000..491713da9 --- /dev/null +++ b/java/com/google/domain/registry/dns/writer/dnsupdate/BUILD @@ -0,0 +1,26 @@ +package( + default_visibility = ["//java/com/google/domain/registry:registry_project"], +) + + +java_library( + name = "dnsupdate", + srcs = glob(["*.java"]), + deps = [ + "//java/com/google/common/annotations", + "//java/com/google/common/base", + "//java/com/google/common/collect", + "//java/com/google/common/io", + "//java/com/google/common/net", + "//java/com/google/common/primitives", + "//java/com/google/domain/registry/config", + "//java/com/google/domain/registry/dns/writer/api", + "//java/com/google/domain/registry/model", + "//java/com/google/domain/registry/util", + "//third_party/java/joda_time", + "//third_party/java/dagger", + "//third_party/java/dnsjava", + "//third_party/java/jsr305_annotations", + "//third_party/java/jsr330_inject", + ], +) diff --git a/java/com/google/domain/registry/dns/writer/dnsupdate/DnsMessageTransport.java b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsMessageTransport.java new file mode 100644 index 000000000..f206402bc --- /dev/null +++ b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsMessageTransport.java @@ -0,0 +1,135 @@ +// Copyright 2016 The Domain Registry Authors. All Rights Reserved. +// +// 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.domain.registry.dns.writer.dnsupdate; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Verify.verify; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.primitives.Ints; +import com.google.domain.registry.config.ConfigModule.Config; + +import org.joda.time.Duration; +import org.xbill.DNS.Message; +import org.xbill.DNS.Opcode; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.ByteBuffer; + +import javax.inject.Inject; +import javax.net.SocketFactory; + +/** + * A transport for DNS messages. Sends/receives DNS messages over TCP using old-style {@link Socket} + * s and the message framing defined in RFC 1035. + * We would like use the dnsjava library's {@link SimpleResolver} class for this, but it requires + * {@link SocketChannel} which is not supported on AppEngine. + */ +public class DnsMessageTransport { + + /** + * Size of message length field for DNS TCP transport. + * + * @see RFC 1035 + */ + static final int MESSAGE_LENGTH_FIELD_BYTES = 2; + private static final int MESSAGE_MAXIMUM_LENGTH = (1 << (MESSAGE_LENGTH_FIELD_BYTES * 8)) - 1; + + /** + * The standard DNS port number. + * + * @see RFC 1035 + */ + @VisibleForTesting static final int DNS_PORT = 53; + + private final SocketFactory factory; + private final String updateHost; + private final int updateTimeout; + + /** + * Class constructor. + * + * @param factory a factory for TCP sockets + * @param updateHost host name of the DNS server + * @param updateTimeout update I/O timeout + */ + @Inject + public DnsMessageTransport( + SocketFactory factory, + @Config("dnsUpdateHost") String updateHost, + @Config("dnsUpdateTimeout") Duration updateTimeout) { + this.factory = factory; + this.updateHost = updateHost; + this.updateTimeout = Ints.checkedCast(updateTimeout.getMillis()); + } + + /** + * Sends a DNS "query" message (most likely an UPDATE) and returns the response. The response is + * checked for matching ID and opcode. + * + * @param query a message to send + * @return the response received from the server + * @throws IOException if the Socket input/output streams throws one + * @throws IllegalArgumentException if the query is too large to be sent (> 65535 bytes) + */ + public Message send(Message query) throws IOException { + try (Socket socket = factory.createSocket(InetAddress.getByName(updateHost), DNS_PORT)) { + socket.setSoTimeout(updateTimeout); + writeMessage(socket.getOutputStream(), query); + Message response = readMessage(socket.getInputStream()); + checkValidResponse(query, response); + return response; + } + } + + private void checkValidResponse(Message query, Message response) { + verify( + response.getHeader().getID() == query.getHeader().getID(), + "response ID %s does not match query ID %s", + response.getHeader().getID(), + query.getHeader().getID()); + verify( + response.getHeader().getOpcode() == query.getHeader().getOpcode(), + "response opcode '%s' does not match query opcode '%s'", + Opcode.string(response.getHeader().getOpcode()), + Opcode.string(query.getHeader().getOpcode())); + } + + private void writeMessage(OutputStream outputStream, Message message) throws IOException { + byte[] messageData = message.toWire(); + checkArgument( + messageData.length <= MESSAGE_MAXIMUM_LENGTH, + "DNS request message larger than maximum of %s: %s", + MESSAGE_MAXIMUM_LENGTH, + messageData.length); + ByteBuffer buffer = ByteBuffer.allocate(messageData.length + MESSAGE_LENGTH_FIELD_BYTES); + buffer.putShort((short) messageData.length); + buffer.put(messageData); + outputStream.write(buffer.array()); + } + + private Message readMessage(InputStream inputStream) throws IOException { + DataInputStream stream = new DataInputStream(inputStream); + int length = stream.readUnsignedShort(); + byte[] messageData = new byte[length]; + stream.readFully(messageData); + return new Message(messageData); + } +} diff --git a/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateConfigModule.java b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateConfigModule.java new file mode 100644 index 000000000..156861d00 --- /dev/null +++ b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateConfigModule.java @@ -0,0 +1,54 @@ +// Copyright 2016 The Domain Registry Authors. All Rights Reserved. +// +// 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.domain.registry.dns.writer.dnsupdate; + +import com.google.domain.registry.config.ConfigModule.Config; + +import org.joda.time.Duration; + +import dagger.Module; +import dagger.Provides; + +@Module +public class DnsUpdateConfigModule { + + /** + * Host that receives DNS updates from the registry. + * Usually a "hidden master" for the TLDs. + */ + @Provides + @Config("dnsUpdateHost") + public static String provideDnsUpdateHost() { + return "localhost"; + } + + /** + * Timeout on the socket for DNS update requests. + */ + @Provides + @Config("dnsUpdateTimeout") + public static Duration provideDnsUpdateTimeout() { + return Duration.standardSeconds(30); + } + + /** + * The DNS time-to-live (TTL) for resource records created by the registry. + */ + @Provides + @Config("dnsUpdateTimeToLive") + public static Duration provideDnsUpdateTimeToLive() { + return Duration.standardHours(2); + } +} diff --git a/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriter.java b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriter.java new file mode 100644 index 000000000..8a2878eaf --- /dev/null +++ b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriter.java @@ -0,0 +1,215 @@ +// Copyright 2016 The Domain Registry Authors. All Rights Reserved. +// +// 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.domain.registry.dns.writer.dnsupdate; + +import static com.google.common.base.Verify.verify; +import static com.google.domain.registry.model.EppResourceUtils.loadByUniqueId; + +import com.google.common.net.InternetDomainName; +import com.google.domain.registry.config.ConfigModule.Config; +import com.google.domain.registry.dns.writer.api.DnsWriter; +import com.google.domain.registry.model.domain.DomainResource; +import com.google.domain.registry.model.domain.secdns.DelegationSignerData; +import com.google.domain.registry.model.host.HostResource; +import com.google.domain.registry.model.registry.Registries; +import com.google.domain.registry.util.Clock; + +import org.joda.time.Duration; +import org.xbill.DNS.AAAARecord; +import org.xbill.DNS.ARecord; +import org.xbill.DNS.DClass; +import org.xbill.DNS.DSRecord; +import org.xbill.DNS.Message; +import org.xbill.DNS.NSRecord; +import org.xbill.DNS.Name; +import org.xbill.DNS.RRset; +import org.xbill.DNS.Rcode; +import org.xbill.DNS.TextParseException; +import org.xbill.DNS.Type; +import org.xbill.DNS.Update; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; + +import javax.inject.Inject; + +/** + * A DnsWriter that implements the DNS UPDATE protocol as specified in + * RFC 2136. Publishes changes in the + * domain-registry to a (capable) external DNS server, sometimes called a "hidden master". DNS + * UPDATE messages are sent via a "resolver" class which implements the network transport. For each + * publish call, a single UPDATE message is created containing the records required to "synchronize" + * the DNS with the current (at the time of processing) state of the registry, for the supplied + * domain/host. + * + *

The general strategy of the publish methods is to delete all resource records of any + * type that match the exact domain/host name supplied. And then for create/update cases, + * add any required records. Deleting all records of any type assumes that the registry is + * authoritative for all records for names in the zone. This seems appropriate for a TLD DNS server, + * which should only contain records required for proper DNS delegation. + * + *

Only NS, DS, A, and AAAA records are published, and in particular no DNSSEC signing is done + * assuming that this will be done by a third party DNS provider. + * + *

Each publish call is treated as an atomic update to the DNS. If an update fails an exception is + * thrown, expecting the caller to retry the update later. The SOA record serial number is + * implicitly incremented by the server on each UPDATE message, as required by RFC 2136. Care must + * be taken to make sure the SOA serial number does not go backwards if the entire TLD (zone) is + * "reset" to empty and republished. + */ +public class DnsUpdateWriter implements DnsWriter { + + private final Duration dnsTimeToLive; + private final DnsMessageTransport resolver; + private final Clock clock; + + /** + * Class constructor. + * + * @param dnsTimeToLive TTL used for any created resource records + * @param resolver a resolver used to send/receive the UPDATE messages + * @param clock a source of time + */ + @Inject + public DnsUpdateWriter( + @Config("dnsUpdateTimeToLive") Duration dnsTimeToLive, + DnsMessageTransport resolver, + Clock clock) { + this.dnsTimeToLive = dnsTimeToLive; + this.resolver = resolver; + this.clock = clock; + } + + @Override + public void publishDomain(String domainName) { + DomainResource domain = loadByUniqueId(DomainResource.class, domainName, clock.nowUtc()); + try { + Update update = new Update(toAbsoluteName(findTldFromName(domainName))); + update.delete(toAbsoluteName(domainName), Type.ANY); + if (domain != null && domain.shouldPublishToDns()) { + update.add(makeNameServerSet(domainName, domain.loadNameservers())); + update.add(makeDelegationSignerSet(domainName, domain.getDsData())); + } + + Message response = resolver.send(update); + verify( + response.getRcode() == Rcode.NOERROR, + "DNS server failed domain update for '%s' rcode: %s", + domainName, + Rcode.string(response.getRcode())); + } catch (IOException e) { + throw new RuntimeException("publishDomain failed: " + domainName, e); + } + } + + @Override + public void publishHost(String hostName) { + HostResource host = loadByUniqueId(HostResource.class, hostName, clock.nowUtc()); + try { + Update update = new Update(toAbsoluteName(findTldFromName(hostName))); + update.delete(toAbsoluteName(hostName), Type.ANY); + if (host != null) { + update.add(makeAddressSet(hostName, host.getInetAddresses())); + update.add(makeV6AddressSet(hostName, host.getInetAddresses())); + } + + Message response = resolver.send(update); + verify( + response.getRcode() == Rcode.NOERROR, + "DNS server failed host update for '%s' rcode: %s", + hostName, + Rcode.string(response.getRcode())); + } catch (IOException e) { + throw new RuntimeException("publishHost failed: " + hostName, e); + } + } + + /** + * Does nothing. Publish calls are synchronous and atomic. + */ + @Override + public void close() {} + + private RRset makeDelegationSignerSet(String domainName, Iterable dsData) + throws TextParseException { + RRset signerSet = new RRset(); + for (DelegationSignerData signerData : dsData) { + DSRecord dsRecord = + new DSRecord( + toAbsoluteName(domainName), + DClass.IN, + dnsTimeToLive.getStandardSeconds(), + signerData.getKeyTag(), + signerData.getAlgorithm(), + signerData.getDigestType(), + signerData.getDigest()); + signerSet.addRR(dsRecord); + } + return signerSet; + } + + private RRset makeNameServerSet(String domainName, Iterable nameservers) + throws TextParseException { + RRset nameServerSet = new RRset(); + for (HostResource host : nameservers) { + NSRecord record = + new NSRecord( + toAbsoluteName(domainName), + DClass.IN, + dnsTimeToLive.getStandardSeconds(), + toAbsoluteName(host.getFullyQualifiedHostName())); + nameServerSet.addRR(record); + } + return nameServerSet; + } + + private RRset makeAddressSet(String hostName, Iterable addresses) + throws TextParseException { + RRset addressSet = new RRset(); + for (InetAddress address : addresses) { + if (address instanceof Inet4Address) { + ARecord record = + new ARecord( + toAbsoluteName(hostName), DClass.IN, dnsTimeToLive.getStandardSeconds(), address); + addressSet.addRR(record); + } + } + return addressSet; + } + + private RRset makeV6AddressSet(String hostName, Iterable addresses) + throws TextParseException { + RRset addressSet = new RRset(); + for (InetAddress address : addresses) { + if (address instanceof Inet6Address) { + AAAARecord record = + new AAAARecord( + toAbsoluteName(hostName), DClass.IN, dnsTimeToLive.getStandardSeconds(), address); + addressSet.addRR(record); + } + } + return addressSet; + } + + private String findTldFromName(String name) { + return Registries.findTldForNameOrThrow(InternetDomainName.from(name)).toString(); + } + + private Name toAbsoluteName(String name) throws TextParseException { + return Name.fromString(name, Name.root); + } +} diff --git a/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriterModule.java b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriterModule.java new file mode 100644 index 000000000..034f7a580 --- /dev/null +++ b/java/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriterModule.java @@ -0,0 +1,37 @@ +// Copyright 2016 The Domain Registry Authors. All Rights Reserved. +// +// 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.domain.registry.dns.writer.dnsupdate; + +import com.google.domain.registry.dns.writer.api.DnsWriter; + +import javax.net.SocketFactory; + +import dagger.Module; +import dagger.Provides; + +/** Dagger module that provides a DnsUpdateWriter. */ +@Module +public final class DnsUpdateWriterModule { + + @Provides + static DnsWriter provideDnsWriter(DnsUpdateWriter dnsWriter) { + return dnsWriter; + } + + @Provides + static SocketFactory provideSocketFactory() { + return SocketFactory.getDefault(); + } +} diff --git a/java/com/google/domain/registry/module/backend/BUILD b/java/com/google/domain/registry/module/backend/BUILD index a5dcb601f..15fafa579 100644 --- a/java/com/google/domain/registry/module/backend/BUILD +++ b/java/com/google/domain/registry/module/backend/BUILD @@ -16,6 +16,7 @@ java_library( "//java/com/google/domain/registry/cron", "//java/com/google/domain/registry/dns", "//java/com/google/domain/registry/dns/writer/api", + "//java/com/google/domain/registry/dns/writer/dnsupdate", "//java/com/google/domain/registry/export", "//java/com/google/domain/registry/export/sheet", "//java/com/google/domain/registry/flows", diff --git a/java/com/google/domain/registry/repositories.bzl b/java/com/google/domain/registry/repositories.bzl index 503d16988..82c820b69 100644 --- a/java/com/google/domain/registry/repositories.bzl +++ b/java/com/google/domain/registry/repositories.bzl @@ -206,6 +206,12 @@ def domain_registry_repositories(): sha1 = "80276338d1c2542ebebac542b535d1ecd48a3fd7", ) + native.maven_jar( + name = "dnsjava", + artifact = "dnsjava:dnsjava:2.1.7", + sha1 = "0a1ed0a251d22bf528cebfafb94c55e6f3f339cf", + ) + native.maven_jar( name = "eclipse_jdt_core", artifact = "org.eclipse.jdt:org.eclipse.jdt.core:3.10.0", diff --git a/javatests/com/google/domain/registry/dns/writer/dnsupdate/DnsMessageTransportTest.java b/javatests/com/google/domain/registry/dns/writer/dnsupdate/DnsMessageTransportTest.java new file mode 100644 index 000000000..88b94bdab --- /dev/null +++ b/javatests/com/google/domain/registry/dns/writer/dnsupdate/DnsMessageTransportTest.java @@ -0,0 +1,207 @@ +// Copyright 2016 The Domain Registry Authors. All Rights Reserved. +// +// 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.domain.registry.dns.writer.dnsupdate; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.base.VerifyException; + +import org.joda.time.Duration; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.xbill.DNS.ARecord; +import org.xbill.DNS.DClass; +import org.xbill.DNS.Flags; +import org.xbill.DNS.Message; +import org.xbill.DNS.Name; +import org.xbill.DNS.Opcode; +import org.xbill.DNS.Rcode; +import org.xbill.DNS.Record; +import org.xbill.DNS.Type; +import org.xbill.DNS.Update; +import org.xbill.DNS.utils.base16; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.util.Arrays; + +import javax.net.SocketFactory; + +/** Unit tests for {@link DnsMessageTransport}. */ +@RunWith(MockitoJUnitRunner.class) +public class DnsMessageTransportTest { + + private static final String UPDATE_HOST = "127.0.0.1"; + + @Mock private SocketFactory mockFactory; + @Mock private Socket mockSocket; + private Message simpleQuery; + private Message expectedResponse; + private DnsMessageTransport resolver; + + @Rule public ExpectedException thrown = ExpectedException.none(); + + @Before + public void before() throws Exception { + simpleQuery = + Message.newQuery(Record.newRecord(Name.fromString("example.com."), Type.A, DClass.IN)); + expectedResponse = responseMessageWithCode(simpleQuery, Rcode.NOERROR); + when(mockFactory.createSocket(InetAddress.getByName(UPDATE_HOST), DnsMessageTransport.DNS_PORT)) + .thenReturn(mockSocket); + resolver = new DnsMessageTransport(mockFactory, UPDATE_HOST, Duration.ZERO); + } + + @Test + public void sentMessageHasCorrectLengthAndContent() throws Exception { + ByteArrayInputStream inputStream = + new ByteArrayInputStream(messageToBytesWithLength(expectedResponse)); + when(mockSocket.getInputStream()).thenReturn(inputStream); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + when(mockSocket.getOutputStream()).thenReturn(outputStream); + + resolver.send(simpleQuery); + + ByteBuffer sentMessage = ByteBuffer.wrap(outputStream.toByteArray()); + int messageLength = sentMessage.getShort(); + byte[] messageData = new byte[messageLength]; + sentMessage.get(messageData); + assertThat(messageLength).isEqualTo(simpleQuery.toWire().length); + assertThat(base16.toString(messageData)).isEqualTo(base16.toString(simpleQuery.toWire())); + } + + @Test + public void receivedMessageWithLengthHasCorrectContent() throws Exception { + ByteArrayInputStream inputStream = + new ByteArrayInputStream(messageToBytesWithLength(expectedResponse)); + when(mockSocket.getInputStream()).thenReturn(inputStream); + when(mockSocket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + + Message actualResponse = resolver.send(simpleQuery); + + assertThat(base16.toString(actualResponse.toWire())) + .isEqualTo(base16.toString(expectedResponse.toWire())); + } + + @Test + public void eofReceivingResponse() throws Exception { + byte[] messageBytes = messageToBytesWithLength(expectedResponse); + ByteArrayInputStream inputStream = + new ByteArrayInputStream(Arrays.copyOf(messageBytes, messageBytes.length - 1)); + when(mockSocket.getInputStream()).thenReturn(inputStream); + when(mockSocket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + thrown.expect(EOFException.class); + + Message expectedQuery = new Message(); + resolver.send(expectedQuery); + } + + @Test + public void timeoutReceivingResponse() throws Exception { + InputStream mockInputStream = mock(InputStream.class); + when(mockInputStream.read()).thenThrow(new SocketTimeoutException("testing")); + when(mockSocket.getInputStream()).thenReturn(mockInputStream); + when(mockSocket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + + Duration testTimeout = Duration.standardSeconds(1); + DnsMessageTransport resolver = new DnsMessageTransport(mockFactory, UPDATE_HOST, testTimeout); + Message expectedQuery = new Message(); + try { + resolver.send(expectedQuery); + fail("exception expected"); + } catch (SocketTimeoutException e) { + verify(mockSocket).setSoTimeout((int) testTimeout.getMillis()); + } + } + + @Test + public void sentMessageTooLongThrowsException() throws Exception { + Update oversize = new Update(Name.fromString("tld", Name.root)); + for (int i = 0; i < 2000; i++) { + oversize.add( + ARecord.newRecord( + Name.fromString("test-extremely-long-name-" + i + ".tld", Name.root), + Type.A, + DClass.IN)); + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + when(mockSocket.getOutputStream()).thenReturn(outputStream); + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("message larger than maximum"); + + resolver.send(oversize); + } + + @Test + public void responseIdMismatchThrowsExeption() throws Exception { + expectedResponse.getHeader().setID(1 + simpleQuery.getHeader().getID()); + when(mockSocket.getInputStream()) + .thenReturn(new ByteArrayInputStream(messageToBytesWithLength(expectedResponse))); + when(mockSocket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + thrown.expect(VerifyException.class); + thrown.expectMessage( + "response ID " + + expectedResponse.getHeader().getID() + + " does not match query ID " + + simpleQuery.getHeader().getID()); + + resolver.send(simpleQuery); + } + + @Test + public void responseOpcodeMismatchThrowsException() throws Exception { + simpleQuery.getHeader().setOpcode(Opcode.QUERY); + expectedResponse.getHeader().setOpcode(Opcode.STATUS); + when(mockSocket.getInputStream()) + .thenReturn(new ByteArrayInputStream(messageToBytesWithLength(expectedResponse))); + when(mockSocket.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + thrown.expect(VerifyException.class); + thrown.expectMessage("response opcode 'STATUS' does not match query opcode 'QUERY'"); + + resolver.send(simpleQuery); + } + + private Message responseMessageWithCode(Message query, int responseCode) { + Message message = new Message(query.getHeader().getID()); + message.getHeader().setOpcode(query.getHeader().getOpcode()); + message.getHeader().setFlag(Flags.QR); + message.getHeader().setRcode(responseCode); + return message; + } + + private byte[] messageToBytesWithLength(Message message) throws IOException { + byte[] bytes = message.toWire(); + ByteBuffer buffer = + ByteBuffer.allocate(bytes.length + DnsMessageTransport.MESSAGE_LENGTH_FIELD_BYTES); + buffer.putShort((short) bytes.length); + buffer.put(bytes); + return buffer.array(); + } +} diff --git a/javatests/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriterTest.java b/javatests/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriterTest.java new file mode 100644 index 000000000..fc0a9400a --- /dev/null +++ b/javatests/com/google/domain/registry/dns/writer/dnsupdate/DnsUpdateWriterTest.java @@ -0,0 +1,302 @@ +// Copyright 2016 The Domain Registry Authors. All Rights Reserved. +// +// 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.domain.registry.dns.writer.dnsupdate; + +import static com.google.common.io.BaseEncoding.base16; +import static com.google.common.truth.Truth.assertThat; +import static com.google.domain.registry.testing.DatastoreHelper.createTld; +import static com.google.domain.registry.testing.DatastoreHelper.persistActiveDomain; +import static com.google.domain.registry.testing.DatastoreHelper.persistActiveHost; +import static com.google.domain.registry.testing.DatastoreHelper.persistActiveSubordinateHost; +import static com.google.domain.registry.testing.DatastoreHelper.persistDeletedDomain; +import static com.google.domain.registry.testing.DatastoreHelper.persistDeletedHost; +import static com.google.domain.registry.testing.DatastoreHelper.persistResource; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.base.VerifyException; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.net.InetAddresses; +import com.google.domain.registry.model.domain.DomainResource; +import com.google.domain.registry.model.domain.ReferenceUnion; +import com.google.domain.registry.model.domain.secdns.DelegationSignerData; +import com.google.domain.registry.model.eppcommon.StatusValue; +import com.google.domain.registry.model.host.HostResource; +import com.google.domain.registry.model.ofy.Ofy; +import com.google.domain.registry.testing.AppEngineRule; +import com.google.domain.registry.testing.FakeClock; +import com.google.domain.registry.testing.InjectRule; + +import org.joda.time.DateTime; +import org.joda.time.Duration; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.xbill.DNS.Flags; +import org.xbill.DNS.Message; +import org.xbill.DNS.Opcode; +import org.xbill.DNS.RRset; +import org.xbill.DNS.Rcode; +import org.xbill.DNS.Record; +import org.xbill.DNS.Section; +import org.xbill.DNS.Type; +import org.xbill.DNS.Update; + +import java.util.ArrayList; +import java.util.Iterator; + +import junit.framework.AssertionFailedError; + +/** Unit tests for {@link DnsUpdateWriter}. */ +@RunWith(MockitoJUnitRunner.class) +public class DnsUpdateWriterTest { + + @Rule + public final AppEngineRule appEngine = + AppEngineRule.builder().withDatastore().withTaskQueue().build(); + + @Rule public ExpectedException thrown = ExpectedException.none(); + + @Rule public final InjectRule inject = new InjectRule(); + + private final FakeClock clock = new FakeClock(DateTime.parse("1971-01-01TZ")); + + @Mock private DnsMessageTransport mockResolver; + @Captor private ArgumentCaptor updateCaptor; + private DelegationSignerData testSignerData = + DelegationSignerData.create(1, 3, 1, base16().decode("0123456789ABCDEF")); + private DnsUpdateWriter writer; + + @Before + public void setUp() throws Exception { + inject.setStaticField(Ofy.class, "clock", clock); + + createTld("tld"); + when(mockResolver.send(any(Update.class))).thenReturn(messageWithResponseCode(Rcode.NOERROR)); + + writer = new DnsUpdateWriter(Duration.ZERO, mockResolver, clock); + } + + @Test + public void publishDomainCreatePublishesNameServers() throws Exception { + HostResource host1 = persistActiveHost("ns1.example.tld"); + HostResource host2 = persistActiveHost("ns2.example.tld"); + DomainResource domain = + persistActiveDomain("example.tld") + .asBuilder() + .setNameservers( + ImmutableSet.of(ReferenceUnion.create(host1), ReferenceUnion.create(host2))) + .build(); + persistResource(domain); + + writer.publishDomain("example.tld"); + + verify(mockResolver).send(updateCaptor.capture()); + Update update = updateCaptor.getValue(); + assertThatUpdatedZoneIs(update, "tld."); + assertThatUpdateDeletes(update, "example.tld.", Type.ANY); + assertThatUpdateAdds(update, "example.tld.", Type.NS, "ns1.example.tld.", "ns2.example.tld."); + assertThatTotalUpdateSetsIs(update, 2); // The delete and NS sets + } + + @Test + public void publishDomainCreatePublishesDelegationSigner() throws Exception { + DomainResource domain = + persistActiveDomain("example.tld") + .asBuilder() + .setNameservers( + ImmutableSet.of(ReferenceUnion.create(persistActiveHost("ns1.example.tld")))) + .setDsData(ImmutableSet.of(testSignerData)) + .build(); + persistResource(domain); + + writer.publishDomain("example.tld"); + + verify(mockResolver).send(updateCaptor.capture()); + Update update = updateCaptor.getValue(); + assertThatUpdatedZoneIs(update, "tld."); + assertThatUpdateDeletes(update, "example.tld.", Type.ANY); + assertThatUpdateAdds(update, "example.tld.", Type.NS, "ns1.example.tld."); + assertThatUpdateAdds(update, "example.tld.", Type.DS, "1 3 1 0123456789ABCDEF"); + assertThatTotalUpdateSetsIs(update, 3); // The delete, the NS, and DS sets + } + + @Test + public void publishDomainWhenNotActiveRemovesDnsRecords() throws Exception { + DomainResource domain = + persistActiveDomain("example.tld") + .asBuilder() + .addStatusValue(StatusValue.SERVER_HOLD) + .setNameservers( + ImmutableSet.of(ReferenceUnion.create(persistActiveHost("ns1.example.tld")))) + .build(); + persistResource(domain); + + writer.publishDomain("example.tld"); + + verify(mockResolver).send(updateCaptor.capture()); + Update update = updateCaptor.getValue(); + assertThatUpdatedZoneIs(update, "tld."); + assertThatUpdateDeletes(update, "example.tld.", Type.ANY); + assertThatTotalUpdateSetsIs(update, 1); // Just the delete set + } + + @Test + public void publishDomainDeleteRemovesDnsRecords() throws Exception { + persistDeletedDomain("example.tld", clock.nowUtc()); + + writer.publishDomain("example.tld"); + + verify(mockResolver).send(updateCaptor.capture()); + Update update = updateCaptor.getValue(); + assertThatUpdatedZoneIs(update, "tld."); + assertThatUpdateDeletes(update, "example.tld.", Type.ANY); + assertThatTotalUpdateSetsIs(update, 1); // Just the delete set + } + + @Test + public void publishHostCreatePublishesAddressRecords() throws Exception { + HostResource host = + persistActiveSubordinateHost("ns1.example.tld", persistActiveDomain("example.tld")) + .asBuilder() + .setInetAddresses( + ImmutableSet.of( + InetAddresses.forString("10.0.0.1"), + InetAddresses.forString("10.1.0.1"), + InetAddresses.forString("fd0e:a5c8:6dfb:6a5e:0:0:0:1"))) + .build(); + persistResource(host); + + writer.publishHost("ns1.example.tld"); + + verify(mockResolver).send(updateCaptor.capture()); + Update update = updateCaptor.getValue(); + assertThatUpdatedZoneIs(update, "tld."); + assertThatUpdateDeletes(update, "ns1.example.tld.", Type.ANY); + assertThatUpdateAdds(update, "ns1.example.tld.", Type.A, "10.0.0.1", "10.1.0.1"); + assertThatUpdateAdds(update, "ns1.example.tld.", Type.AAAA, "fd0e:a5c8:6dfb:6a5e:0:0:0:1"); + assertThatTotalUpdateSetsIs(update, 3); // The delete, the A, and AAAA sets + } + + @Test + public void publishHostDeleteRemovesDnsRecords() throws Exception { + persistDeletedHost("ns1.example.tld", clock.nowUtc()); + + writer.publishHost("ns1.example.tld"); + + verify(mockResolver).send(updateCaptor.capture()); + Update update = updateCaptor.getValue(); + assertThatUpdatedZoneIs(update, "tld."); + assertThatUpdateDeletes(update, "ns1.example.tld.", Type.ANY); + assertThatTotalUpdateSetsIs(update, 1); // Just the delete set + } + + @Test + public void publishDomainFailsWhenDnsUpdateReturnsError() throws Exception { + DomainResource domain = + persistActiveDomain("example.tld") + .asBuilder() + .setNameservers( + ImmutableSet.of(ReferenceUnion.create(persistActiveHost("ns1.example.tld")))) + .build(); + persistResource(domain); + when(mockResolver.send(any(Message.class))).thenReturn(messageWithResponseCode(Rcode.SERVFAIL)); + thrown.expect(VerifyException.class); + thrown.expectMessage("SERVFAIL"); + + writer.publishDomain("example.tld"); + } + + @Test + public void publishHostFailsWhenDnsUpdateReturnsError() throws Exception { + HostResource host = + persistActiveSubordinateHost("ns1.example.tld", persistActiveDomain("example.tld")) + .asBuilder() + .setInetAddresses(ImmutableSet.of(InetAddresses.forString("10.0.0.1"))) + .build(); + persistResource(host); + when(mockResolver.send(any(Message.class))).thenReturn(messageWithResponseCode(Rcode.SERVFAIL)); + thrown.expect(VerifyException.class); + thrown.expectMessage("SERVFAIL"); + + writer.publishHost("ns1.example.tld"); + } + + private void assertThatUpdatedZoneIs(Update update, String zoneName) { + Record[] zoneRecords = update.getSectionArray(Section.ZONE); + assertThat(zoneRecords[0].getName().toString()).isEqualTo(zoneName); + } + + private void assertThatTotalUpdateSetsIs(Update update, int count) { + assertThat(update.getSectionRRsets(Section.UPDATE)).hasLength(count); + } + + private void assertThatUpdateDeletes(Update update, String resourceName, int recordType) { + ImmutableList deleted = findUpdateRecords(update, resourceName, recordType); + // There's only an empty (i.e. "delete") record. + assertThat(deleted.get(0).rdataToString()).hasLength(0); + assertThat(deleted).hasSize(1); + } + + private void assertThatUpdateAdds( + Update update, String resourceName, int recordType, String... resourceData) { + ArrayList expectedData = new ArrayList<>(); + for (String resourceDatum : resourceData) { + expectedData.add(resourceDatum.toLowerCase()); + } + + ArrayList actualData = new ArrayList<>(); + for (Record record : findUpdateRecords(update, resourceName, recordType)) { + actualData.add(record.rdataToString().toLowerCase()); + } + assertThat(actualData).containsExactlyElementsIn(expectedData); + } + + private ImmutableList findUpdateRecords( + Update update, String resourceName, int recordType) { + for (RRset set : update.getSectionRRsets(Section.UPDATE)) { + if (set.getName().toString().equals(resourceName) && set.getType() == recordType) { + return fixIterator(Record.class, set.rrs()); + } + } + throw new AssertionFailedError( + "no record set found for resource '" + + resourceName + + "', type '" + + Type.string(recordType) + + "'"); + } + + @SuppressWarnings({"unchecked", "unused"}) + private static ImmutableList fixIterator(Class clazz, final Iterator iterator) { + return ImmutableList.copyOf((Iterator) iterator); + } + + private Message messageWithResponseCode(int responseCode) { + Message message = new Message(); + message.getHeader().setOpcode(Opcode.UPDATE); + message.getHeader().setFlag(Flags.QR); + message.getHeader().setRcode(responseCode); + return message; + } +} diff --git a/javatests/com/google/domain/registry/server/RegistryTestServer.java b/javatests/com/google/domain/registry/server/RegistryTestServer.java index 850315fd8..c980fda6d 100644 --- a/javatests/com/google/domain/registry/server/RegistryTestServer.java +++ b/javatests/com/google/domain/registry/server/RegistryTestServer.java @@ -68,6 +68,10 @@ public final class RegistryTestServer { route("/_dr/task/nordnVerify", com.google.domain.registry.module.backend.BackendServlet.class), + // Process DNS pull queue + route("/_dr/task/writeDns", + com.google.domain.registry.module.backend.BackendServlet.class), + // Registrar Console route("/registrar", com.google.domain.registry.module.frontend.FrontendServlet.class), route("/registrar-settings", diff --git a/third_party/java/dnsjava/BUILD b/third_party/java/dnsjava/BUILD new file mode 100644 index 000000000..84e6e90d6 --- /dev/null +++ b/third_party/java/dnsjava/BUILD @@ -0,0 +1,8 @@ +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) # BSD 2-Clause + +java_library( + name = "dnsjava", + exports = ["@dnsjava//jar"], +)