From 9b96c832382605e69f8d16ed634036f7821f8188 Mon Sep 17 00:00:00 2001 From: Mike Yu Date: Mon, 18 Sep 2023 08:26:08 +0000 Subject: [PATCH 1/3] Add DnsSvcbRecord This change is an initial implementation of DNS SVCB Record. It can parse an SVCB Record in wire format. The RDATA field of a SVCB Record is formatted as follows: - SvcPriority: 2-byte field - TargetName: a domain name that follows RFC1035 section 5.1 - SvcParams (optional): a list of SvcParam This initial implementation focuses on the SvcParams related to DDR, including alpn, port, ipv4hint, ipv6hint, and dohpath. For the other SvcParams, such as mandatory and ech, this change doesn't check whether their value is valid. Bug: 240259333 Test: atest ConnectivityCoverageTests Test: No test for DnsSvcbRecord for now. will add some tests in a follow-up change Change-Id: Icfe9b623eda8390c40b106123a718cdd2af3af99 --- staticlibs/Android.bp | 4 + .../android/net/module/util/DnsPacket.java | 5 +- .../net/module/util/DnsSvcbRecord.java | 516 ++++++++++++++++++ .../net/module/util/DnsPacketTest.java | 24 + 4 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp index 0bcb757a0f..25a92910a4 100644 --- a/staticlibs/Android.bp +++ b/staticlibs/Android.bp @@ -96,12 +96,16 @@ java_library { srcs: [ "framework/**/DnsPacket.java", "framework/**/DnsPacketUtils.java", + "framework/**/DnsSvcbRecord.java", + "framework/**/HexDump.java", + "framework/**/NetworkStackConstants.java", ], sdk_version: "module_current", visibility: [ "//packages/services/Iwlan:__subpackages__", ], libs: [ + "androidx.annotation_annotation", "framework-annotations-lib", "framework-connectivity.stubs.module_lib", ], diff --git a/staticlibs/framework/com/android/net/module/util/DnsPacket.java b/staticlibs/framework/com/android/net/module/util/DnsPacket.java index 0dcdf1e659..63106a191d 100644 --- a/staticlibs/framework/com/android/net/module/util/DnsPacket.java +++ b/staticlibs/framework/com/android/net/module/util/DnsPacket.java @@ -56,6 +56,7 @@ public abstract class DnsPacket { */ // TODO: Define the constant as a public constant in DnsResolver since it can never change. private static final int TYPE_CNAME = 5; + public static final int TYPE_SVCB = 64; /** * Thrown when parsing packet failed. @@ -282,7 +283,7 @@ public abstract class DnsPacket { * @param buf ByteBuffer input of record, must be in network byte order * (which is the default). */ - private DnsRecord(@RecordType int rType, @NonNull ByteBuffer buf) + protected DnsRecord(@RecordType int rType, @NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException { Objects.requireNonNull(buf); this.rType = rType; @@ -326,6 +327,8 @@ public abstract class DnsPacket { // Return a DnsRecord instance by default for backward compatibility, this is useful // when a partner supports new type of DnsRecord but does not inherit DnsRecord. switch (nsType) { + case TYPE_SVCB: + return new DnsSvcbRecord(rType, buf); default: return new DnsRecord(rType, buf); } diff --git a/staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java b/staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java new file mode 100644 index 0000000000..669725cd6f --- /dev/null +++ b/staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java @@ -0,0 +1,516 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * 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.android.net.module.util; + +import static android.net.DnsResolver.CLASS_IN; + +import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE; +import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE; +import static com.android.net.module.util.DnsPacket.ParseException; + +import android.annotation.NonNull; +import android.text.TextUtils; +import android.util.SparseArray; + +import com.android.internal.annotations.VisibleForTesting; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ShortBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.StringJoiner; + +/** + * A class for an SVCB record. + * https://www.iana.org/assignments/dns-svcb/dns-svcb.xhtml + * @hide + */ +@VisibleForTesting(visibility = PACKAGE) +public final class DnsSvcbRecord extends DnsPacket.DnsRecord { + /** + * The following SvcParamKeys KEY_* are defined in + * https://www.iana.org/assignments/dns-svcb/dns-svcb.xhtml. + */ + + // The SvcParamKey "mandatory". The associated implementation of SvcParam is SvcParamMandatory. + private static final int KEY_MANDATORY = 0; + + // The SvcParamKey "alpn". The associated implementation of SvcParam is SvcParamAlpn. + private static final int KEY_ALPN = 1; + + // The SvcParamKey "no-default-alpn". The associated implementation of SvcParam is + // SvcParamNoDefaultAlpn. + private static final int KEY_NO_DEFAULT_ALPN = 2; + + // The SvcParamKey "port". The associated implementation of SvcParam is SvcParamPort. + private static final int KEY_PORT = 3; + + // The SvcParamKey "ipv4hint". The associated implementation of SvcParam is SvcParamIpv4Hint. + private static final int KEY_IPV4HINT = 4; + + // The SvcParamKey "ech". The associated implementation of SvcParam is SvcParamEch. + private static final int KEY_ECH = 5; + + // The SvcParamKey "ipv6hint". The associated implementation of SvcParam is SvcParamIpv6Hint. + private static final int KEY_IPV6HINT = 6; + + // The SvcParamKey "dohpath". The associated implementation of SvcParam is SvcParamDohPath. + private static final int KEY_DOHPATH = 7; + + // The minimal size of a SvcParam. + // https://www.ietf.org/archive/id/draft-ietf-dnsop-svcb-https-12.html#name-rdata-wire-format + private static final int MINSVCPARAMSIZE = 4; + + private static final String TAG = DnsSvcbRecord.class.getSimpleName(); + + private final int mSvcPriority; + + @NonNull + private final String mTargetName; + + @NonNull + private final SparseArray mAllSvcParams = new SparseArray<>(); + + @VisibleForTesting(visibility = PACKAGE) + public DnsSvcbRecord(@DnsPacket.RecordType int rType, @NonNull ByteBuffer buff) + throws IllegalStateException, ParseException { + super(rType, buff); + if (nsType != DnsPacket.TYPE_SVCB) { + throw new IllegalStateException("incorrect nsType: " + nsType); + } + if (nsClass != CLASS_IN) { + throw new ParseException("incorrect nsClass: " + nsClass); + } + + // DNS Record in Question Section doesn't have Rdata. + if (rType == DnsPacket.QDSECTION) { + mSvcPriority = 0; + mTargetName = ""; + return; + } + + final byte[] rdata = getRR(); + if (rdata == null) { + throw new ParseException("SVCB rdata is empty"); + } + + final ByteBuffer buf = ByteBuffer.wrap(rdata).asReadOnlyBuffer(); + mSvcPriority = Short.toUnsignedInt(buf.getShort()); + mTargetName = DnsPacketUtils.DnsRecordParser.parseName(buf, 0 /* Parse depth */, + false /* isNameCompressionSupported */); + + if (mTargetName.length() > DnsPacket.DnsRecord.MAXNAMESIZE) { + throw new ParseException( + "Failed to parse SVCB target name, name size is too long: " + + mTargetName.length()); + } + while (buf.remaining() >= MINSVCPARAMSIZE) { + final SvcParam svcParam = parseSvcParam(buf); + final int key = svcParam.getKey(); + if (mAllSvcParams.get(key) != null) { + throw new ParseException("Invalid DnsSvcbRecord, key " + key + " is repeated"); + } + mAllSvcParams.put(key, svcParam); + } + if (buf.hasRemaining()) { + throw new ParseException("Invalid DnsSvcbRecord. Got " + + buf.remaining() + " remaining bytes after parsing"); + } + } + + /** + * Returns the TargetName. + */ + @VisibleForTesting(visibility = PACKAGE) + @NonNull + public String getTargetName() { + return mTargetName; + } + + /** + * Returns an unmodifiable list of alpns from SvcParam alpn. + */ + @VisibleForTesting(visibility = PACKAGE) + @NonNull + public List getAlpns() { + final SvcParamAlpn sp = (SvcParamAlpn) mAllSvcParams.get(KEY_ALPN); + final List list = (sp != null) ? sp.getValue() : Collections.EMPTY_LIST; + return Collections.unmodifiableList(list); + } + + /** + * Returns the port number from SvcParam port. + */ + @VisibleForTesting(visibility = PACKAGE) + public int getPort() { + final SvcParamPort sp = (SvcParamPort) mAllSvcParams.get(KEY_PORT); + return (sp != null) ? sp.getValue() : -1; + } + + /** + * Returns a list of the IP addresses from both of SvcParam ipv4hint and ipv6hint. + */ + @VisibleForTesting(visibility = PACKAGE) + @NonNull + public List getAddresses() { + final List out = new ArrayList<>(); + final SvcParamIpHint sp4 = (SvcParamIpHint) mAllSvcParams.get(KEY_IPV4HINT); + if (sp4 != null) { + out.addAll(sp4.getValue()); + } + final SvcParamIpHint sp6 = (SvcParamIpHint) mAllSvcParams.get(KEY_IPV6HINT); + if (sp6 != null) { + out.addAll(sp6.getValue()); + } + return out; + } + + /** + * Returns the doh path from SvcParam dohPath. + */ + @VisibleForTesting(visibility = PACKAGE) + @NonNull + public String getDohPath() { + final SvcParamDohPath sp = (SvcParamDohPath) mAllSvcParams.get(KEY_DOHPATH); + return (sp != null) ? sp.getValue() : ""; + } + + @Override + public String toString() { + if (rType == DnsPacket.QDSECTION) { + return dName + " IN SVCB"; + } + + final StringJoiner sj = new StringJoiner(" "); + for (int i = 0; i < mAllSvcParams.size(); i++) { + sj.add(mAllSvcParams.valueAt(i).toString()); + } + return dName + " " + ttl + " IN SVCB " + mSvcPriority + " " + mTargetName + " " + + sj.toString(); + } + + private static SvcParam parseSvcParam(@NonNull ByteBuffer buf) throws ParseException { + try { + final int key = Short.toUnsignedInt(buf.getShort()); + switch (key) { + case KEY_MANDATORY: return new SvcParamMandatory(buf); + case KEY_ALPN: return new SvcParamAlpn(buf); + case KEY_NO_DEFAULT_ALPN: return new SvcParamNoDefaultAlpn(buf); + case KEY_PORT: return new SvcParamPort(buf); + case KEY_IPV4HINT: return new SvcParamIpv4Hint(buf); + case KEY_ECH: return new SvcParamEch(buf); + case KEY_IPV6HINT: return new SvcParamIpv6Hint(buf); + case KEY_DOHPATH: return new SvcParamDohPath(buf); + default: return new SvcParamGeneric(key, buf); + } + } catch (BufferUnderflowException e) { + throw new ParseException("Malformed packet", e); + } + } + + /** + * The base class for all SvcParam. + */ + private abstract static class SvcParam { + private final int mKey; + + SvcParam(int key) { + mKey = key; + } + + int getKey() { + return mKey; + } + } + + private static class SvcParamMandatory extends SvcParam { + private final short[] mValue; + + private SvcParamMandatory(@NonNull ByteBuffer buf) throws BufferUnderflowException, + ParseException { + super(KEY_MANDATORY); + // The caller already read 2 bytes for SvcParamKey. + final int len = Short.toUnsignedInt(buf.getShort()); + final ByteBuffer svcParamValue = sliceAndAdvance(buf, len); + mValue = SvcParamValueUtil.toShortArray(svcParamValue); + if (mValue.length == 0) { + throw new ParseException("mandatory value must be non-empty"); + } + } + + @Override + public String toString() { + final StringJoiner valueJoiner = new StringJoiner(","); + for (short key : mValue) { + valueJoiner.add(toKeyName(key)); + } + return toKeyName(getKey()) + "=" + valueJoiner.toString(); + } + } + + private static class SvcParamAlpn extends SvcParam { + private final List mValue; + + SvcParamAlpn(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException { + super(KEY_ALPN); + // The caller already read 2 bytes for SvcParamKey. + final int len = Short.toUnsignedInt(buf.getShort()); + final ByteBuffer svcParamValue = sliceAndAdvance(buf, len); + mValue = SvcParamValueUtil.toStringList(svcParamValue); + if (mValue.isEmpty()) { + throw new ParseException("alpn value must be non-empty"); + } + } + + List getValue() { + return Collections.unmodifiableList(mValue); + } + + @Override + public String toString() { + return toKeyName(getKey()) + "=" + TextUtils.join(",", mValue); + } + } + + private static class SvcParamNoDefaultAlpn extends SvcParam { + SvcParamNoDefaultAlpn(@NonNull ByteBuffer buf) throws BufferUnderflowException, + ParseException { + super(KEY_NO_DEFAULT_ALPN); + // The caller already read 2 bytes for SvcParamKey. + final int len = buf.getShort(); + if (len != 0) { + throw new ParseException("no-default-alpn value must be empty"); + } + } + + @Override + public String toString() { + return toKeyName(getKey()); + } + } + + private static class SvcParamPort extends SvcParam { + private final int mValue; + + SvcParamPort(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException { + super(KEY_PORT); + // The caller already read 2 bytes for SvcParamKey. + final int len = buf.getShort(); + if (len != Short.BYTES) { + throw new ParseException("key port len is not 2 but " + len); + } + mValue = Short.toUnsignedInt(buf.getShort()); + } + + int getValue() { + return mValue; + } + + @Override + public String toString() { + return toKeyName(getKey()) + "=" + mValue; + } + } + + private static class SvcParamIpHint extends SvcParam { + private final List mValue; + + private SvcParamIpHint(int key, @NonNull ByteBuffer buf, int addrLen) throws + BufferUnderflowException, ParseException { + super(key); + // The caller already read 2 bytes for SvcParamKey. + final int len = Short.toUnsignedInt(buf.getShort()); + final ByteBuffer svcParamValue = sliceAndAdvance(buf, len); + mValue = SvcParamValueUtil.toInetAddressList(svcParamValue, addrLen); + if (mValue.isEmpty()) { + throw new ParseException(toKeyName(getKey()) + " value must be non-empty"); + } + } + + List getValue() { + return Collections.unmodifiableList(mValue); + } + + @Override + public String toString() { + final StringJoiner valueJoiner = new StringJoiner(","); + for (InetAddress ip : mValue) { + valueJoiner.add(ip.getHostAddress()); + } + return toKeyName(getKey()) + "=" + valueJoiner.toString(); + } + } + + private static class SvcParamIpv4Hint extends SvcParamIpHint { + SvcParamIpv4Hint(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException { + super(KEY_IPV4HINT, buf, NetworkStackConstants.IPV4_ADDR_LEN); + } + } + + private static class SvcParamIpv6Hint extends SvcParamIpHint { + SvcParamIpv6Hint(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException { + super(KEY_IPV6HINT, buf, NetworkStackConstants.IPV6_ADDR_LEN); + } + } + + private static class SvcParamEch extends SvcParamGeneric { + SvcParamEch(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException { + super(KEY_ECH, buf); + } + } + + private static class SvcParamDohPath extends SvcParam { + private final String mValue; + + SvcParamDohPath(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException { + super(KEY_DOHPATH); + // The caller already read 2 bytes for SvcParamKey. + final int len = Short.toUnsignedInt(buf.getShort()); + final byte[] value = new byte[len]; + buf.get(value); + mValue = new String(value, StandardCharsets.UTF_8); + } + + String getValue() { + return mValue; + } + + @Override + public String toString() { + return toKeyName(getKey()) + "=" + mValue; + } + } + + // For other unrecognized and unimplemented SvcParams, they are stored as SvcParamGeneric. + private static class SvcParamGeneric extends SvcParam { + private final byte[] mValue; + + SvcParamGeneric(int key, @NonNull ByteBuffer buf) throws BufferUnderflowException, + ParseException { + super(key); + // The caller already read 2 bytes for SvcParamKey. + final int len = Short.toUnsignedInt(buf.getShort()); + mValue = new byte[len]; + buf.get(mValue); + } + + @Override + public String toString() { + final StringBuilder out = new StringBuilder(); + out.append(toKeyName(getKey())); + if (mValue != null && mValue.length > 0) { + out.append("="); + out.append(HexDump.toHexString(mValue)); + } + return out.toString(); + } + } + + private static String toKeyName(int key) { + switch (key) { + case KEY_MANDATORY: return "mandatory"; + case KEY_ALPN: return "alpn"; + case KEY_NO_DEFAULT_ALPN: return "no-default-alpn"; + case KEY_PORT: return "port"; + case KEY_IPV4HINT: return "ipv4hint"; + case KEY_ECH: return "ech"; + case KEY_IPV6HINT: return "ipv6hint"; + case KEY_DOHPATH: return "dohpath"; + default: return "key" + key; + } + } + + /** + * Returns a read-only ByteBuffer (with position = 0, limit = `length`, and capacity = `length`) + * sliced from `buf`'s current position, and moves the position of `buf` by `length`. + */ + @VisibleForTesting(visibility = PRIVATE) + public static ByteBuffer sliceAndAdvance(@NonNull ByteBuffer buf, int length) + throws BufferUnderflowException { + if (buf.remaining() < length) { + throw new BufferUnderflowException(); + } + final int pos = buf.position(); + + // `out` equals to `buf.slice(pos, length)` that is supported in API level 34. + final ByteBuffer out = ((ByteBuffer) buf.slice().limit(length)).slice(); + + buf.position(pos + length); + return out.asReadOnlyBuffer(); + } + + // A utility to convert the byte array of SvcParamValue to other types. + private static class SvcParamValueUtil { + // Refer to draft-ietf-dnsop-svcb-https-10#section-7.1 for the wire format of alpn. + @NonNull + private static List toStringList(@NonNull ByteBuffer buf) + throws BufferUnderflowException, ParseException { + final List out = new ArrayList<>(); + while (buf.hasRemaining()) { + final int alpnLen = Byte.toUnsignedInt(buf.get()); + if (alpnLen == 0) { + throw new ParseException("alpn should not be an empty string"); + } + final byte[] alpn = new byte[alpnLen]; + buf.get(alpn); + out.add(new String(alpn, StandardCharsets.UTF_8)); + } + return out; + } + + // Refer to draft-ietf-dnsop-svcb-https-10#section-7.5 for the wire format of SvcParamKey + // "mandatory". + @NonNull + private static short[] toShortArray(@NonNull ByteBuffer buf) + throws BufferUnderflowException, ParseException { + if (buf.remaining() % Short.BYTES != 0) { + throw new ParseException("Can't parse whole byte array"); + } + final ShortBuffer sb = buf.asShortBuffer(); + final short[] out = new short[sb.remaining()]; + sb.get(out); + return out; + } + + // Refer to draft-ietf-dnsop-svcb-https-10#section-7.4 for the wire format of ipv4hint and + // ipv6hint. + @NonNull + private static List toInetAddressList(@NonNull ByteBuffer buf, int addrLen) + throws BufferUnderflowException, ParseException { + if (buf.remaining() % addrLen != 0) { + throw new ParseException("Can't parse whole byte array"); + } + + final List out = new ArrayList<>(); + final byte[] addr = new byte[addrLen]; + while (buf.remaining() >= addrLen) { + buf.get(addr); + try { + out.add(InetAddress.getByAddress(addr)); + } catch (UnknownHostException e) { + throw new ParseException("Can't parse byte array as an IP address"); + } + } + return out; + } + } +} diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/DnsPacketTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/DnsPacketTest.java index 28e183a95e..88d9e1e27e 100644 --- a/staticlibs/tests/unit/src/com/android/net/module/util/DnsPacketTest.java +++ b/staticlibs/tests/unit/src/com/android/net/module/util/DnsPacketTest.java @@ -203,6 +203,30 @@ public class DnsPacketTest { "test.com", CLASS_IN, 0 /* ttl */, "example.com")); } + /** Verifies that the type of implementation returned from DnsRecord#parse is correct */ + @Test + public void testDnsRecordParse() throws IOException { + final byte[] svcbQuestionRecord = new byte[] { + 0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00, /* Name */ + 0x00, 0x40, /* Type */ + 0x00, 0x01, /* Class */ + }; + assertTrue(DnsPacket.DnsRecord.parse(DnsPacket.QDSECTION, + ByteBuffer.wrap(svcbQuestionRecord)) instanceof DnsSvcbRecord); + + final byte[] svcbAnswerRecord = new byte[] { + 0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00, /* Name */ + 0x00, 0x40, /* Type */ + 0x00, 0x01, /* Class */ + 0x00, 0x00, 0x01, 0x2b, /* TTL */ + 0x00, 0x0b, /* Data length */ + 0x00, 0x01, /* SvcPriority */ + 0x03, 'd', 'o', 't', 0x03, 'c', 'o', 'm', 0x00, /* TargetName */ + }; + assertTrue(DnsPacket.DnsRecord.parse(DnsPacket.ANSECTION, + ByteBuffer.wrap(svcbAnswerRecord)) instanceof DnsSvcbRecord); + } + /** * Verifies ttl/rData error handling when parsing * {@link DnsPacket.DnsRecord} from bytes. From 8404b3c93e4ffbce933d1aec382b56e33761ac08 Mon Sep 17 00:00:00 2001 From: Mike Yu Date: Mon, 18 Sep 2023 08:27:51 +0000 Subject: [PATCH 2/3] Add DnsSvcbPacket DnsSvcbPacket will be used to parse DNS packet of type SVCB. Bug: 240259333 Test: will add some tests in a follow-up change Change-Id: Icb13d50f2d95a898b3b7ea373db876f41690723a --- staticlibs/Android.bp | 1 + .../net/module/util/DnsSvcbPacket.java | 182 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp index 25a92910a4..6325b46f7a 100644 --- a/staticlibs/Android.bp +++ b/staticlibs/Android.bp @@ -96,6 +96,7 @@ java_library { srcs: [ "framework/**/DnsPacket.java", "framework/**/DnsPacketUtils.java", + "framework/**/DnsSvcbPacket.java", "framework/**/DnsSvcbRecord.java", "framework/**/HexDump.java", "framework/**/NetworkStackConstants.java", diff --git a/staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java b/staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java new file mode 100644 index 0000000000..c7ed3e6f29 --- /dev/null +++ b/staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * 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.android.net.module.util; + +import static android.net.DnsResolver.TYPE_A; +import static android.net.DnsResolver.TYPE_AAAA; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.StringJoiner; + +/** + * A class for a DNS SVCB response packet. + * + * @hide + */ +public class DnsSvcbPacket extends DnsPacket { + public static final int TYPE_SVCB = 64; + + private static final String TAG = DnsSvcbPacket.class.getSimpleName(); + + /** + * Creates a DnsSvcbPacket object from the given wire-format DNS packet. + */ + private DnsSvcbPacket(@NonNull byte[] data) throws DnsPacket.ParseException { + // If data is null, ParseException will be thrown. + super(data); + + final int questions = mHeader.getRecordCount(QDSECTION); + if (questions != 1) { + throw new DnsPacket.ParseException("Unexpected question count " + questions); + } + final int nsType = mRecords[QDSECTION].get(0).nsType; + if (nsType != TYPE_SVCB) { + throw new DnsPacket.ParseException("Unexpected query type " + nsType); + } + } + + /** + * Returns true if the DnsSvcbPacket is a DNS response. + */ + public boolean isResponse() { + return mHeader.isResponse(); + } + + /** + * Returns whether the given protocol alpn is supported. + */ + public boolean isSupported(@NonNull String alpn) { + return findSvcbRecord(alpn) != null; + } + + /** + * Returns the TargetName associated with the given protocol alpn. + * If the alpn is not supported, a null is returned. + */ + @Nullable + public String getTargetName(@NonNull String alpn) { + final DnsSvcbRecord record = findSvcbRecord(alpn); + return (record != null) ? record.getTargetName() : null; + } + + /** + * Returns the TargetName that associated with the given protocol alpn. + * If the alpn is not supported, -1 is returned. + */ + public int getPort(@NonNull String alpn) { + final DnsSvcbRecord record = findSvcbRecord(alpn); + return (record != null) ? record.getPort() : -1; + } + + /** + * Returns the IP addresses that support the given protocol alpn. + * If the alpn is not supported, an empty list is returned. + */ + @NonNull + public List getAddresses(@NonNull String alpn) { + final DnsSvcbRecord record = findSvcbRecord(alpn); + if (record == null) return Collections.EMPTY_LIST; + + // As per draft-ietf-dnsop-svcb-https-10#section-7.4 and draft-ietf-add-ddr-10#section-4, + // if A/AAAA records are available in the Additional section, use the IP addresses in + // those records instead of the IP addresses in ipv4hint/ipv6hint. + final List out = getAddressesFromAdditionalSection(); + if (out.size() > 0) return out; + + return record.getAddresses(); + } + + /** + * Returns the value of SVCB key dohpath that associated with the given protocol alpn. + * If the alpn is not supported, a null is returned. + */ + @Nullable + public String getDohPath(@NonNull String alpn) { + final DnsSvcbRecord record = findSvcbRecord(alpn); + return (record != null) ? record.getDohPath() : null; + } + + /** + * Returns the DnsSvcbRecord associated with the given protocol alpn. + * If the alpn is not supported, a null is returned. + */ + @Nullable + private DnsSvcbRecord findSvcbRecord(@NonNull String alpn) { + for (final DnsRecord record : mRecords[ANSECTION]) { + if (record instanceof DnsSvcbRecord) { + final DnsSvcbRecord svcbRecord = (DnsSvcbRecord) record; + if (svcbRecord.getAlpns().contains(alpn)) { + return svcbRecord; + } + } + } + return null; + } + + /** + * Returns the IP addresses in additional section. + */ + @NonNull + private List getAddressesFromAdditionalSection() { + final List out = new ArrayList(); + if (mHeader.getRecordCount(ARSECTION) == 0) { + return out; + } + for (final DnsRecord record : mRecords[ARSECTION]) { + if (record.nsType != TYPE_A && record.nsType != TYPE_AAAA) { + Log.d(TAG, "Found type other than A/AAAA in Additional section: " + record.nsType); + continue; + } + try { + out.add(InetAddress.getByAddress(record.getRR())); + } catch (UnknownHostException e) { + Log.w(TAG, "Failed to parse address"); + } + } + return out; + } + + @Override + public String toString() { + final StringJoiner out = new StringJoiner(" "); + out.add("QUERY: [" + TextUtils.join(", ", mRecords[QDSECTION]) + "]"); + out.add("ANSWER: [" + TextUtils.join(", ", mRecords[ANSECTION]) + "]"); + out.add("AUTHORITY: [" + TextUtils.join(", ", mRecords[NSSECTION]) + "]"); + out.add("ADDITIONAL: [" + TextUtils.join(", ", mRecords[ARSECTION]) + "]"); + return out.toString(); + } + + /** + * Creates a DnsSvcbPacket object from the given wire-format DNS answer. + */ + public static DnsSvcbPacket fromResponse(@NonNull byte[] data) throws DnsPacket.ParseException { + DnsSvcbPacket out = new DnsSvcbPacket(data); + if (!out.isResponse()) { + throw new DnsPacket.ParseException("Not an answer packet"); + } + return out; + } +} From f5032571a8003e8013da355e7cea84f33664f8d0 Mon Sep 17 00:00:00 2001 From: Mike Yu Date: Mon, 18 Sep 2023 08:30:16 +0000 Subject: [PATCH 3/3] Add tests for DnsSvcbRecord and DnsSvcbPacket This change adds the test coverage for DnsSvcbRecord and DnsSvcbPacket. Bug: 240259333 Test: atest ConnectivityCoverageTests Change-Id: I0b6328337fd61c5ac2e5f6b3737238f7d678245a --- .../net/module/util/DnsSvcbPacketTest.java | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java new file mode 100644 index 0000000000..6778f8abe1 --- /dev/null +++ b/staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java @@ -0,0 +1,606 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * 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.android.net.module.util; + +import static android.net.DnsResolver.CLASS_IN; +import static android.net.DnsResolver.TYPE_A; +import static android.net.DnsResolver.TYPE_AAAA; + +import static com.android.net.module.util.DnsPacket.TYPE_SVCB; +import static com.android.testutils.MiscAsserts.assertThrows; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.annotation.NonNull; +import android.net.InetAddresses; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@RunWith(JUnit4.class) +public class DnsSvcbPacketTest { + private static final short TEST_TRANSACTION_ID = 0x4321; + private static final byte[] TEST_DNS_RESPONSE_HEADER_FLAG = new byte[] { (byte) 0x81, 0x00 }; + + // A common DNS SVCB Question section with Name = "_dns.resolver.arpa". + private static final byte[] TEST_DNS_SVCB_QUESTION_SECTION = new byte[] { + 0x04, '_', 'd', 'n', 's', 0x08, 'r', 'e', 's', 'o', 'l', 'v', 'e', 'r', + 0x04, 'a', 'r', 'p', 'a', 0x00, 0x00, 0x40, 0x00, 0x01, + }; + + // mandatory=ipv4hint,alpn,key333 + private static final byte[] TEST_SVC_PARAM_MANDATORY = new byte[] { + 0x00, 0x00, 0x00, 0x06, 0x00, 0x04, 0x00, 0x01, 0x01, 0x4d, + }; + + // alpn=doq + private static final byte[] TEST_SVC_PARAM_ALPN_DOQ = new byte[] { + 0x00, 0x01, 0x00, 0x04, 0x03, 'd', 'o', 'q' + }; + + // alpn=h2,http/1.1 + private static final byte[] TEST_SVC_PARAM_ALPN_HTTPS = new byte[] { + 0x00, 0x01, 0x00, 0x0c, 0x02, 'h', '2', + 0x08, 'h', 't', 't', 'p', '/', '1', '.', '1', + }; + + // no-default-alpn + private static final byte[] TEST_SVC_PARAM_NO_DEFAULT_ALPN = new byte[] { + 0x00, 0x02, 0x00, 0x00, + }; + + // port=5353 + private static final byte[] TEST_SVC_PARAM_PORT = new byte[] { + 0x00, 0x03, 0x00, 0x02, 0x14, (byte) 0xe9, + }; + + // ipv4hint=1.2.3.4,6.7.8.9 + private static final byte[] TEST_SVC_PARAM_IPV4HINT_1 = new byte[] { + 0x00, 0x04, 0x00, 0x08, 0x01, 0x02, 0x03, 0x04, 0x06, 0x07, 0x08, 0x09, + }; + + // ipv4hint=4.3.2.1 + private static final byte[] TEST_SVC_PARAM_IPV4HINT_2 = new byte[] { + 0x00, 0x04, 0x00, 0x04, 0x04, 0x03, 0x02, 0x01, + }; + + // ech=aBcDe + private static final byte[] TEST_SVC_PARAM_ECH = new byte[] { + 0x00, 0x05, 0x00, 0x05, 'a', 'B', 'c', 'D', 'e', + }; + + // ipv6hint=2001:db8::1 + private static final byte[] TEST_SVC_PARAM_IPV6HINT = new byte[] { + 0x00, 0x06, 0x00, 0x10, 0x20, 0x01, 0x0d, (byte) 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }; + + // dohpath=/some-path{?dns} + private static final byte[] TEST_SVC_PARAM_DOHPATH = new byte[] { + 0x00, 0x07, 0x00, 0x10, + '/', 's', 'o', 'm', 'e', '-', 'p', 'a', 't', 'h', '{', '?', 'd', 'n', 's', '}', + }; + + // key12345=1A2B0C + private static final byte[] TEST_SVC_PARAM_GENERIC_WITH_VALUE = new byte[] { + 0x30, 0x39, 0x00, 0x03, 0x1a, 0x2b, 0x0c, + }; + + // key12346 + private static final byte[] TEST_SVC_PARAM_GENERIC_WITHOUT_VALUE = new byte[] { + 0x30, 0x3a, 0x00, 0x00, + }; + + private static byte[] makeDnsResponseHeaderAsByteArray(int qdcount, int ancount, int nscount, + int arcount) { + final ByteBuffer buffer = ByteBuffer.wrap(new byte[12]); + buffer.putShort(TEST_TRANSACTION_ID); /* Transaction ID */ + buffer.put(TEST_DNS_RESPONSE_HEADER_FLAG); /* Flags */ + buffer.putShort((short) qdcount); + buffer.putShort((short) ancount); + buffer.putShort((short) nscount); + buffer.putShort((short) arcount); + return buffer.array(); + } + + private static DnsSvcbRecord makeDnsSvcbRecordFromByteArray(@NonNull byte[] data) + throws IOException { + return new DnsSvcbRecord(DnsPacket.ANSECTION, ByteBuffer.wrap(data)); + } + + private static DnsSvcbRecord makeDnsSvcbRecordWithSingleSvcParam(@NonNull byte[] svcParam) + throws IOException { + return makeDnsSvcbRecordFromByteArray(new TestDnsRecordByteArrayBuilder() + .setRRType(TYPE_SVCB) + .setTargetName("test.com") + .addRdata(svcParam) + .build()); + } + + // Converts a Short to a byte array in big endian. + private static byte[] shortToByteArray(short value) { + return new byte[] { (byte) (value >> 8), (byte) value }; + } + + private static byte[] getRemainingByteArray(@NonNull ByteBuffer buffer) { + final byte[] out = new byte[buffer.remaining()]; + buffer.get(out); + return out; + } + + // A utility to make a DNS record as byte array. + private static class TestDnsRecordByteArrayBuilder { + private static final byte[] NAME_COMPRESSION_POINTER = new byte[] { (byte) 0xc0, 0x0c }; + + private final String mRRName = "dns.com"; + private short mRRType = 0; + private final short mRRClass = CLASS_IN; + private final int mRRTtl = 10; + private int mRdataLen = 0; + private final ArrayList mRdata = new ArrayList<>(); + private String mTargetName = null; + private short mSvcPriority = 1; + private boolean mNameCompression = false; + + TestDnsRecordByteArrayBuilder setNameCompression(boolean value) { + mNameCompression = value; + return this; + } + + TestDnsRecordByteArrayBuilder setRRType(int value) { + mRRType = (short) value; + return this; + } + + TestDnsRecordByteArrayBuilder setTargetName(@NonNull String value) throws IOException { + mTargetName = value; + return this; + } + + TestDnsRecordByteArrayBuilder setSvcPriority(int value) { + mSvcPriority = (short) value; + return this; + } + + TestDnsRecordByteArrayBuilder addRdata(@NonNull byte[] value) { + mRdata.add(value); + mRdataLen += value.length; + return this; + } + + byte[] build() throws IOException { + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + final byte[] name = mNameCompression ? NAME_COMPRESSION_POINTER + : DnsPacketUtils.DnsRecordParser.domainNameToLabels(mRRName); + os.write(name); + os.write(shortToByteArray(mRRType)); + os.write(shortToByteArray(mRRClass)); + os.write(HexDump.toByteArray(mRRTtl)); + if (mTargetName == null) { + os.write(shortToByteArray((short) mRdataLen)); + } else { + final byte[] targetNameLabels = + DnsPacketUtils.DnsRecordParser.domainNameToLabels(mTargetName); + mRdataLen += (Short.BYTES + targetNameLabels.length); + os.write(shortToByteArray((short) mRdataLen)); + os.write(shortToByteArray(mSvcPriority)); + os.write(targetNameLabels); + } + for (byte[] data : mRdata) { + os.write(data); + } + return os.toByteArray(); + } + } + + @Test + public void testSliceAndAdvance() throws Exception { + final ByteBuffer buffer = ByteBuffer.wrap(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9}); + final ByteBuffer slice1 = DnsSvcbRecord.sliceAndAdvance(buffer, 3); + final ByteBuffer slice2 = DnsSvcbRecord.sliceAndAdvance(buffer, 4); + assertEquals(0, slice1.position()); + assertEquals(3, slice1.capacity()); + assertEquals(3, slice1.remaining()); + assertTrue(slice1.isReadOnly()); + assertArrayEquals(new byte[] {1, 2, 3}, getRemainingByteArray(slice1)); + assertEquals(0, slice2.position()); + assertEquals(4, slice2.capacity()); + assertEquals(4, slice2.remaining()); + assertTrue(slice2.isReadOnly()); + assertArrayEquals(new byte[] {4, 5, 6, 7}, getRemainingByteArray(slice2)); + + // Nothing is read if out-of-bound access happens. + assertThrows(BufferUnderflowException.class, + () -> DnsSvcbRecord.sliceAndAdvance(buffer, 5)); + assertEquals(7, buffer.position()); + assertEquals(9, buffer.capacity()); + assertEquals(2, buffer.remaining()); + assertArrayEquals(new byte[] {8, 9}, getRemainingByteArray(buffer)); + } + + @Test + public void testDnsSvcbRecord_svcParamMandatory() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam(TEST_SVC_PARAM_MANDATORY); + // Check the content returned from toString() for now because the getter function for + // this SvcParam hasn't been implemented. + // TODO(b/240259333): Consider adding DnsSvcbRecord.isMandatory(String alpn) when needed. + assertTrue(record.toString().contains("mandatory=ipv4hint,alpn,key333")); + } + + @Test + public void testDnsSvcbRecord_svcParamAlpn() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam(TEST_SVC_PARAM_ALPN_HTTPS); + assertEquals(Arrays.asList("h2", "http/1.1"), record.getAlpns()); + } + + @Test + public void testDnsSvcbRecord_svcParamNoDefaultAlpn() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam( + TEST_SVC_PARAM_NO_DEFAULT_ALPN); + // Check the content returned from toString() for now because the getter function for + // this SvcParam hasn't been implemented. + // TODO(b/240259333): Consider adding DnsSvcbRecord.hasNoDefaultAlpn() when needed. + assertTrue(record.toString().contains("no-default-alpn")); + } + + @Test + public void testDnsSvcbRecord_svcParamPort() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam(TEST_SVC_PARAM_PORT); + assertEquals(5353, record.getPort()); + } + + @Test + public void testDnsSvcbRecord_svcParamIpv4Hint() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam(TEST_SVC_PARAM_IPV4HINT_2); + assertEquals(Arrays.asList(InetAddresses.parseNumericAddress("4.3.2.1")), + record.getAddresses()); + } + + @Test + public void testDnsSvcbRecord_svcParamEch() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam(TEST_SVC_PARAM_ECH); + // Check the content returned from toString() for now because the getter function for + // this SvcParam hasn't been implemented. + // TODO(b/240259333): Consider adding DnsSvcbRecord.getEch() when needed. + assertTrue(record.toString().contains("ech=6142634465")); + } + + @Test + public void testDnsSvcbRecord_svcParamIpv6Hint() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam(TEST_SVC_PARAM_IPV6HINT); + assertEquals(Arrays.asList(InetAddresses.parseNumericAddress("2001:db8::1")), + record.getAddresses()); + } + + @Test + public void testDnsSvcbRecord_svcParamDohPath() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam(TEST_SVC_PARAM_DOHPATH); + assertEquals("/some-path{?dns}", record.getDohPath()); + } + + @Test + public void testDnsSvcbRecord_svcParamGeneric_withValue() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam( + TEST_SVC_PARAM_GENERIC_WITH_VALUE); + // Check the content returned from toString() for now because the getter function for + // generic SvcParam hasn't been implemented. + // TODO(b/240259333): Consider adding DnsSvcbRecord.getValueFromGenericSvcParam(int key) + // when needed. + assertTrue(record.toString().contains("key12345=1A2B0C")); + } + + @Test + public void testDnsSvcbRecord_svcParamGeneric_withoutValue() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordWithSingleSvcParam( + TEST_SVC_PARAM_GENERIC_WITHOUT_VALUE); + // Check the content returned from toString() for now because the getter function for + // generic SvcParam hasn't been implemented. + // TODO(b/240259333): Consider adding DnsSvcbRecord.getValueFromGenericSvcParam(int key) + // when needed. + assertTrue(record.toString().contains("key12346")); + } + + @Test + public void testDnsSvcbRecord() throws Exception { + final DnsSvcbRecord record = makeDnsSvcbRecordFromByteArray( + new TestDnsRecordByteArrayBuilder() + .setRRType(TYPE_SVCB) + .setTargetName("doh.dns.com") + .addRdata(TEST_SVC_PARAM_ALPN_HTTPS) + .addRdata(TEST_SVC_PARAM_IPV4HINT_1) + .addRdata(TEST_SVC_PARAM_IPV6HINT) + .addRdata(TEST_SVC_PARAM_PORT) + .addRdata(TEST_SVC_PARAM_DOHPATH) + .build()); + assertEquals("doh.dns.com", record.getTargetName()); + assertEquals(Arrays.asList("h2", "http/1.1"), record.getAlpns()); + assertEquals(5353, record.getPort()); + assertEquals(Arrays.asList( + InetAddresses.parseNumericAddress("1.2.3.4"), + InetAddresses.parseNumericAddress("6.7.8.9"), + InetAddresses.parseNumericAddress("2001:db8::1")), record.getAddresses()); + assertEquals("/some-path{?dns}", record.getDohPath()); + } + + @Test + public void testDnsSvcbRecord_createdFromNullObject() throws Exception { + assertThrows(NullPointerException.class, () -> makeDnsSvcbRecordFromByteArray(null)); + } + + @Test + public void testDnsSvcbRecord_invalidDnsRecord() throws Exception { + // The type is not SVCB. + final byte[] bytes1 = new TestDnsRecordByteArrayBuilder() + .setRRType(TYPE_A) + .addRdata(InetAddresses.parseNumericAddress("1.2.3.4").getAddress()) + .build(); + assertThrows(IllegalStateException.class, () -> makeDnsSvcbRecordFromByteArray(bytes1)); + + // TargetName is missing. + final byte[] bytes2 = new TestDnsRecordByteArrayBuilder() + .setRRType(TYPE_SVCB) + .addRdata(new byte[] { 0x01, 0x01 }) + .build(); + assertThrows(BufferUnderflowException.class, () -> makeDnsSvcbRecordFromByteArray(bytes2)); + + // Rdata is empty. + final byte[] bytes3 = new TestDnsRecordByteArrayBuilder() + .setRRType(TYPE_SVCB) + .build(); + assertThrows(BufferUnderflowException.class, () -> makeDnsSvcbRecordFromByteArray(bytes3)); + } + + @Test + public void testDnsSvcbRecord_repeatedKeyIsInvalid() throws Exception { + final byte[] bytes = new TestDnsRecordByteArrayBuilder() + .setRRType(TYPE_SVCB) + .addRdata(TEST_SVC_PARAM_ALPN_HTTPS) + .addRdata(TEST_SVC_PARAM_ALPN_DOQ) + .build(); + assertThrows(DnsPacket.ParseException.class, () -> makeDnsSvcbRecordFromByteArray(bytes)); + } + + @Test + public void testDnsSvcbRecord_invalidContent() throws Exception { + final List invalidContents = Arrays.asList( + // Invalid SvcParamValue for "mandatory": + // - SvcParamValue must not be empty. + // - SvcParamValue has less data than expected. + // - SvcParamValue has more data than expected. + // - SvcParamValue must be multiple of 2. + new byte[] { 0x00, 0x00, 0x00, 0x00}, + new byte[] { 0x00, 0x00, 0x00, 0x02, 0x00, 0x04, 0x00, 0x06 }, + new byte[] { 0x00, 0x00, 0x00, 0x04, 0x00, 0x04, }, + new byte[] { 0x00, 0x00, 0x00, 0x03, 0x00, 0x04, 0x00 }, + + // Invalid SvcParamValue for "alpn": + // - SvcParamValue must not be empty. + // - SvcParamValue has less data than expected. + // - SvcParamValue has more data than expected. + // - Alpn length is less than the actual data size. + // - Alpn length is more than the actual data size. + // - Alpn must be a non-empty string. + new byte[] { 0x00, 0x01, 0x00, 0x00}, + new byte[] { 0x00, 0x01, 0x00, 0x02, 0x02, 'h', '2' }, + new byte[] { 0x00, 0x01, 0x00, 0x05, 0x02, 'h', '2' }, + new byte[] { 0x00, 0x01, 0x00, 0x04, 0x02, 'd', 'o', 't' }, + new byte[] { 0x00, 0x01, 0x00, 0x04, 0x08, 'd', 'o', 't' }, + new byte[] { 0x00, 0x01, 0x00, 0x08, 0x02, 'h', '2', 0x00 }, + + // Invalid SvcParamValue for "no-default-alpn": + // - SvcParamValue must be empty. + // - SvcParamValue length must be 0. + new byte[] { 0x00, 0x02, 0x00, 0x04, 'd', 'a', 't', 'a' }, + new byte[] { 0x00, 0x02, 0x00, 0x04 }, + + // Invalid SvcParamValue for "port": + // - SvcParamValue must not be empty. + // - SvcParamValue has less data than expected. + // - SvcParamValue has more data than expected. + // - SvcParamValue length must be multiple of 2. + new byte[] { 0x00, 0x03, 0x00, 0x00 }, + new byte[] { 0x00, 0x03, 0x00, 0x02, 0x01 }, + new byte[] { 0x00, 0x03, 0x00, 0x02, 0x01, 0x02, 0x03 }, + new byte[] { 0x00, 0x03, 0x00, 0x03, 0x01, 0x02, 0x03 }, + + // Invalid SvcParamValue for "ipv4hint": + // - SvcParamValue must not be empty. + // - SvcParamValue has less data than expected. + // - SvcParamValue has more data than expected. + // - SvcParamValue must be multiple of 4. + new byte[] { 0x00, 0x04, 0x00, 0x00 }, + new byte[] { 0x00, 0x04, 0x00, 0x04, 0x08 }, + new byte[] { 0x00, 0x04, 0x00, 0x04, 0x08, 0x08, 0x08, 0x08, 0x08 }, + new byte[] { 0x00, 0x04, 0x00, 0x05, 0x08, 0x08, 0x08, 0x08 }, + + // Invalid SvcParamValue for "ipv6hint": + // - SvcParamValue must not be empty. + // - SvcParamValue has less data than expected. + // - SvcParamValue has more data than expected. + // - SvcParamValue must be multiple of 16. + new byte[] { 0x00, 0x06, 0x00, 0x00 }, + new byte[] { 0x00, 0x06, 0x00, 0x10, 0x01 }, + new byte[] { 0x00, 0x06, 0x00, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17 }, + new byte[] { 0x00, 0x06, 0x00, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16 } + ); + + for (byte[] content : invalidContents) { + final byte[] bytes = new TestDnsRecordByteArrayBuilder() + .setRRType(TYPE_SVCB) + .addRdata(content) + .build(); + assertThrows(DnsPacket.ParseException.class, + () -> makeDnsSvcbRecordFromByteArray(bytes)); + } + } + + @Test + public void testDnsSvcbPacket_createdFromNullObject() throws Exception { + assertThrows(DnsPacket.ParseException.class, () -> DnsSvcbPacket.fromResponse(null)); + } + + @Test + public void testDnsSvcbPacket() throws Exception { + final String dohTargetName = "https.dns.com"; + final String doqTargetName = "doq.dns.com"; + final InetAddress[] expectedIpAddressesForHttps = new InetAddress[] { + InetAddresses.parseNumericAddress("1.2.3.4"), + InetAddresses.parseNumericAddress("6.7.8.9"), + InetAddresses.parseNumericAddress("2001:db8::1"), + }; + final InetAddress[] expectedIpAddressesForDoq = new InetAddress[] { + InetAddresses.parseNumericAddress("4.3.2.1"), + }; + + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + os.write(makeDnsResponseHeaderAsByteArray(1 /* qdcount */, 2 /* ancount */, 0 /* nscount */, + 0 /* arcount */)); + os.write(TEST_DNS_SVCB_QUESTION_SECTION); + // Add answer for alpn h2 and http/1.1. + os.write(new TestDnsRecordByteArrayBuilder() + .setNameCompression(true) + .setRRType(TYPE_SVCB) + .setTargetName(dohTargetName) + .addRdata(TEST_SVC_PARAM_ALPN_HTTPS) + .addRdata(TEST_SVC_PARAM_IPV4HINT_1) + .addRdata(TEST_SVC_PARAM_IPV6HINT) + .addRdata(TEST_SVC_PARAM_PORT) + .addRdata(TEST_SVC_PARAM_DOHPATH) + .build()); + // Add answer for alpn doq. + os.write(new TestDnsRecordByteArrayBuilder() + .setNameCompression(true) + .setRRType(TYPE_SVCB) + .setTargetName(doqTargetName) + .setSvcPriority(2) + .addRdata(TEST_SVC_PARAM_ALPN_DOQ) + .addRdata(TEST_SVC_PARAM_IPV4HINT_2) + .build()); + final DnsSvcbPacket pkt = DnsSvcbPacket.fromResponse(os.toByteArray()); + + assertTrue(pkt.isSupported("http/1.1")); + assertTrue(pkt.isSupported("h2")); + assertTrue(pkt.isSupported("doq")); + assertFalse(pkt.isSupported("http")); + assertFalse(pkt.isSupported("h3")); + assertFalse(pkt.isSupported("")); + + assertEquals(dohTargetName, pkt.getTargetName("http/1.1")); + assertEquals(dohTargetName, pkt.getTargetName("h2")); + assertEquals(doqTargetName, pkt.getTargetName("doq")); + assertEquals(null, pkt.getTargetName("http")); + assertEquals(null, pkt.getTargetName("h3")); + assertEquals(null, pkt.getTargetName("")); + + assertEquals(5353, pkt.getPort("http/1.1")); + assertEquals(5353, pkt.getPort("h2")); + assertEquals(-1, pkt.getPort("doq")); + assertEquals(-1, pkt.getPort("http")); + assertEquals(-1, pkt.getPort("h3")); + assertEquals(-1, pkt.getPort("")); + + assertArrayEquals(expectedIpAddressesForHttps, pkt.getAddresses("http/1.1").toArray()); + assertArrayEquals(expectedIpAddressesForHttps, pkt.getAddresses("h2").toArray()); + assertArrayEquals(expectedIpAddressesForDoq, pkt.getAddresses("doq").toArray()); + assertTrue(pkt.getAddresses("http").isEmpty()); + assertTrue(pkt.getAddresses("h3").isEmpty()); + assertTrue(pkt.getAddresses("").isEmpty()); + + assertEquals("/some-path{?dns}", pkt.getDohPath("http/1.1")); + assertEquals("/some-path{?dns}", pkt.getDohPath("h2")); + assertEquals("", pkt.getDohPath("doq")); + assertEquals(null, pkt.getDohPath("http")); + assertEquals(null, pkt.getDohPath("h3")); + assertEquals(null, pkt.getDohPath("")); + } + + @Test + public void testDnsSvcbPacket_noIpHint() throws Exception { + final String targetName = "doq.dns.com"; + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + os.write(makeDnsResponseHeaderAsByteArray(1 /* qdcount */, 1 /* ancount */, 0 /* nscount */, + 0 /* arcount */)); + os.write(TEST_DNS_SVCB_QUESTION_SECTION); + // Add answer for alpn doq. + os.write(new TestDnsRecordByteArrayBuilder() + .setNameCompression(true) + .setRRType(TYPE_SVCB) + .setTargetName(targetName) + .addRdata(TEST_SVC_PARAM_ALPN_DOQ) + .build()); + final DnsSvcbPacket pkt = DnsSvcbPacket.fromResponse(os.toByteArray()); + + assertTrue(pkt.isSupported("doq")); + assertEquals(targetName, pkt.getTargetName("doq")); + assertEquals(-1, pkt.getPort("doq")); + assertArrayEquals(new InetAddress[] {}, pkt.getAddresses("doq").toArray()); + assertEquals("", pkt.getDohPath("doq")); + } + + @Test + public void testDnsSvcbPacket_hasAnswerInAdditionalSection() throws Exception { + final InetAddress[] expectedIpAddresses = new InetAddress[] { + InetAddresses.parseNumericAddress("1.2.3.4"), + InetAddresses.parseNumericAddress("2001:db8::2"), + }; + + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + os.write(makeDnsResponseHeaderAsByteArray(1 /* qdcount */, 1 /* ancount */, 0 /* nscount */, + 2 /* arcount */)); + os.write(TEST_DNS_SVCB_QUESTION_SECTION); + // Add SVCB record in the Answer section. + os.write(new TestDnsRecordByteArrayBuilder() + .setNameCompression(true) + .setRRType(TYPE_SVCB) + .setTargetName("doq.dns.com") + .addRdata(TEST_SVC_PARAM_ALPN_DOQ) + .addRdata(TEST_SVC_PARAM_IPV4HINT_2) + .addRdata(TEST_SVC_PARAM_IPV6HINT) + .build()); + // Add A/AAAA records in the Additional section. + os.write(new TestDnsRecordByteArrayBuilder() + .setNameCompression(true) + .setRRType(TYPE_A) + .addRdata(InetAddresses.parseNumericAddress("1.2.3.4").getAddress()) + .build()); + os.write(new TestDnsRecordByteArrayBuilder() + .setNameCompression(true) + .setRRType(TYPE_AAAA) + .addRdata(InetAddresses.parseNumericAddress("2001:db8::2").getAddress()) + .build()); + final DnsSvcbPacket pkt = DnsSvcbPacket.fromResponse(os.toByteArray()); + + // If there are A/AAAA records in the Additional section, getAddresses() returns the IP + // addresses in those records instead of the IP addresses in ipv4hint/ipv6hint. + assertArrayEquals(expectedIpAddresses, pkt.getAddresses("doq").toArray()); + } +}