diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp index 0bcb757a0f..6325b46f7a 100644 --- a/staticlibs/Android.bp +++ b/staticlibs/Android.bp @@ -96,12 +96,17 @@ java_library { srcs: [ "framework/**/DnsPacket.java", "framework/**/DnsPacketUtils.java", + "framework/**/DnsSvcbPacket.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/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; + } +} 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. 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()); + } +}