diff --git a/minidns-core/src/main/java/org/minidns/constants/SVCBConstants.java b/minidns-core/src/main/java/org/minidns/constants/SVCBConstants.java new file mode 100644 index 00000000..6f387cef --- /dev/null +++ b/minidns-core/src/main/java/org/minidns/constants/SVCBConstants.java @@ -0,0 +1,23 @@ +package org.minidns.constants; + +import org.minidns.constants.svcbservicekeys.ALPNServiceKey; +import org.minidns.constants.svcbservicekeys.ServiceKeySpecification; +import org.minidns.constants.svcbservicekeys.UnrecognizedServiceKey; + + +public class SVCBConstants { + public static ServiceKeySpecification findServiceKeyByNumber(int number, byte[] blob) { + switch (number) { + case 1: return new ALPNServiceKey(blob); + default: return new UnrecognizedServiceKey(blob, number); + } + } + + // ALPN(1, "alpn"), + // NO_DEFAULT_ALPN(2, "no-default-alpn"), + // PORT(3, "port"), + // IPV4HINT(4, "ipv4hint"), + // ECHOCONFIG(5, "echoconfig"), + // IPV6HINT(6, "ipv6hint"), + // INVALID_KEY(65535, "key65535"); +} diff --git a/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/ALPNServiceKey.java b/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/ALPNServiceKey.java new file mode 100644 index 00000000..75de724a --- /dev/null +++ b/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/ALPNServiceKey.java @@ -0,0 +1,50 @@ +package org.minidns.constants.svcbservicekeys; + +import org.minidns.util.RRTextUtil; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ALPNServiceKey extends ServiceKeySpecification> { + private List value; + + public ALPNServiceKey(byte[] blob) { + super(blob, 1); + } + + @Override + public List value() throws IOException { + if(value == null) { + List values = new ArrayList<>(); + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(blob)); + while(dis.available() > 0) { + byte[] blob = new byte[dis.readUnsignedShort()]; + dis.readFully(blob); + values.add(RRTextUtil.getTextFrom(blob)); + } + value = Collections.unmodifiableList(values); + } + return value; + } + + @Override + public String getTextualRepresentation() { + return "alpn"; + } + + @Override + public String valueAsString() throws IOException { + StringBuilder sb = new StringBuilder(); + for (String s : value()) { + if(sb.length() > 0) { + sb.append(","); + } + sb.append(s.replaceAll(",", "\\\\,")); + } + return sb.toString(); + } +} diff --git a/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/ServiceKeySpecification.java b/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/ServiceKeySpecification.java new file mode 100644 index 00000000..7a34dfbc --- /dev/null +++ b/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/ServiceKeySpecification.java @@ -0,0 +1,31 @@ +package org.minidns.constants.svcbservicekeys; + +import java.io.IOException; + +public abstract class ServiceKeySpecification implements Comparable> { + public final byte[] blob; + public final int number; + + public ServiceKeySpecification(byte[] blob, int number) { + this.blob = blob; + this.number = number; + } + + public final int getNumber() { + return number; + } + + abstract public ValueType value() throws IOException; + abstract public String getTextualRepresentation(); + abstract public String valueAsString() throws IOException; + + @Override + public int compareTo(ServiceKeySpecification other) { + return getNumber() - other.getNumber(); + } + + @Override + public String toString() { + return getTextualRepresentation(); + } +} \ No newline at end of file diff --git a/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/UnrecognizedServiceKey.java b/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/UnrecognizedServiceKey.java new file mode 100644 index 00000000..2bcb8711 --- /dev/null +++ b/minidns-core/src/main/java/org/minidns/constants/svcbservicekeys/UnrecognizedServiceKey.java @@ -0,0 +1,24 @@ +package org.minidns.constants.svcbservicekeys; + +import java.util.Arrays; + +public class UnrecognizedServiceKey extends ServiceKeySpecification{ + public UnrecognizedServiceKey(byte[] blob, int number) { + super(blob, number); + } + + @Override + public byte[] value() { + return blob; + } + + @Override + public String getTextualRepresentation() { + return "key" + number; + } + + @Override + public String valueAsString() { + return Arrays.toString(blob); + } +} diff --git a/minidns-core/src/main/java/org/minidns/record/Record.java b/minidns-core/src/main/java/org/minidns/record/Record.java index 359238fe..37fd4f71 100644 --- a/minidns-core/src/main/java/org/minidns/record/Record.java +++ b/minidns-core/src/main/java/org/minidns/record/Record.java @@ -99,6 +99,7 @@ public enum TYPE { CDNSKEY(60), OPENPGPKEY(61, OPENPGPKEY.class), CSYNC(62), + SVCB(65, SVCB.class), SPF(99), UINFO(100), UID(101), @@ -399,6 +400,9 @@ public static Record parse(DataInputStream dis, byte[] data) throws IOExce case DLV: payloadData = DLV.parse(dis, payloadLength); break; + case SVCB: + payloadData = SVCB.parse(dis, payloadLength, data); + break; case UNKNOWN: default: payloadData = UNKNOWN.parse(dis, payloadLength, type); diff --git a/minidns-core/src/main/java/org/minidns/record/SVCB.java b/minidns-core/src/main/java/org/minidns/record/SVCB.java new file mode 100644 index 00000000..61f88aee --- /dev/null +++ b/minidns-core/src/main/java/org/minidns/record/SVCB.java @@ -0,0 +1,132 @@ +package org.minidns.record; + +import org.minidns.constants.SVCBConstants; +import org.minidns.constants.svcbservicekeys.ServiceKeySpecification; +import org.minidns.dnsname.DnsName; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.TreeSet; + +/** + * SVCB Record Type (Service binding) + * + * @see draft-ietf-dnsop-svcb-https-01: Service binding and parameter specification via the DNS (DNS SVCB and HTTPS RRs) + */ +class SVCB extends RRWithTarget { + + /** + * The priority indicates the SvcPriority. + * A SvcPriority of 0 puts this RR in AliasMode (otherwise ServiceMode). + * + * @see Possible parameter IDs + */ + public final Set> params; + + /** + * @param priority SvcPriority + * @param target TargetName + * @param params SvcParams + */ + public SVCB(int priority, DnsName target, Set> params) { + super(target); + this.priority = priority; + TreeSet> sorted = new TreeSet<>(params); + this.params = Collections.unmodifiableSortedSet(sorted); + } + + /** + * Parses the wireformat data according to the spec. + * + * @see RDATA wire format specification + */ + public static SVCB parse(DataInputStream dis, int length, byte[] data) + throws IOException { + int priority = dis.readUnsignedShort(); + DnsName target = DnsName.parse(dis, data); + Set> params; + + int paramBlobSize = length - 2 - target.getRawBytes().length; + if(paramBlobSize == 0) { + params = Collections.emptySet(); + } else { + params = parseParamsBlob(dis, length); + } + + return new SVCB(priority, target, params); + } + + private static Set> parseParamsBlob(DataInputStream dis, int paramBlobSize) throws IOException { + int remainingBytes = paramBlobSize; + int lastKey = Integer.MIN_VALUE; + Set> params = new HashSet<>(); + + while(remainingBytes > 0) { + int key = dis.readUnsignedShort(); + if(key < lastKey) throw new IllegalArgumentException("SVCB ServiceKeys must be in ascending order (" + key + "<" + lastKey + ")"); + else if(key == lastKey) throw new IllegalArgumentException("SVCB ServiceKeys must not be duplicate (" + key + "=" + lastKey + ")"); + lastKey = key; + + int valueLength = dis.readUnsignedShort(); + byte[] valueBlob = new byte[valueLength]; + if(valueLength != 0) { + dis.readFully(valueBlob); + } + + ServiceKeySpecification detectedKey = SVCBConstants.findServiceKeyByNumber(key, valueBlob); + params.add(detectedKey); + remainingBytes = remainingBytes - 4 - valueLength; + } + return params; + } + + @Override + public void serialize(DataOutputStream dos) throws IOException { + dos.writeShort(priority); + super.serialize(dos); + for (ServiceKeySpecification param: params) { + dos.writeShort(param.blob.length); + dos.write(param.blob); + } + } + + @Override + public Record.TYPE getType() { + return Record.TYPE.SVCB; + } + + @Override + public String toString() { + try { + return priority + " " + target + createValuesString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private String createValuesString() throws IOException { + StringBuilder builder = new StringBuilder(); + for (ServiceKeySpecification param : params) { + builder.append(" "); + builder.append(param.getTextualRepresentation()); + builder.append("="); + builder.append("\""); + builder.append(param.valueAsString()); + builder.append("\""); + } + return builder.toString(); + } +} diff --git a/minidns-core/src/main/java/org/minidns/util/RRTextUtil.java b/minidns-core/src/main/java/org/minidns/util/RRTextUtil.java new file mode 100644 index 00000000..df5caa56 --- /dev/null +++ b/minidns-core/src/main/java/org/minidns/util/RRTextUtil.java @@ -0,0 +1,13 @@ +package org.minidns.util; + +public class RRTextUtil { + + public static String getTextFrom(byte[] blob) { + StringBuilder sb = new StringBuilder(); + return sb.toString(); + } + + public static byte[] textToByteArray(String s) { + return new byte[0]; + } +} diff --git a/minidns-core/src/test/java/org/minidns/record/RecordsTest.java b/minidns-core/src/test/java/org/minidns/record/RecordsTest.java index 1cb3b7e7..dbbf459f 100644 --- a/minidns-core/src/test/java/org/minidns/record/RecordsTest.java +++ b/minidns-core/src/test/java/org/minidns/record/RecordsTest.java @@ -12,6 +12,9 @@ import org.minidns.constants.DnssecConstants.DigestAlgorithm; import org.minidns.constants.DnssecConstants.SignatureAlgorithm; +import org.minidns.constants.svcbservicekeys.ServiceKeySpecification; +import org.minidns.constants.svcbservicekeys.UnrecognizedServiceKey; +import org.minidns.dnsname.DnsName; import org.minidns.record.NSEC3.HashAlgorithm; import org.minidns.record.Record.TYPE; import org.junit.jupiter.api.Test; @@ -22,6 +25,8 @@ import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Set; +import java.util.TreeSet; import static org.minidns.Assert.assertCsEquals; import static org.minidns.Assert.assertArrayContentEquals; @@ -48,6 +53,24 @@ public void testARecord() throws Exception { assertArrayEquals(new byte[] {127, 0, 0, 1}, a.getIp()); } + @Test + public void testSVCBRecord() throws Exception { + Set> params = new TreeSet<>(); + params.add(new UnrecognizedServiceKey(new byte[1], 6)); + params.add(new UnrecognizedServiceKey(new byte[1], 1)); + params.add(new UnrecognizedServiceKey(new byte[1], 3)); + SVCB svcb = new SVCB(1, DnsName.from("example.com"), params); + + assertEquals(TYPE.SVCB, svcb.getType()); + + // The keys should be ordered ascending + String expectedString = "1 example.com alpn=\"nopläintêxt\" port=\"numbers like 1 and very, very long text with spaces in it.\" ipv6hint=\"testing\" 65281=\"unknown\""; + assertEquals(expectedString, svcb.toString()); + byte[] svcbb = svcb.toByteArray(); + svcb = SVCB.parse(new DataInputStream(new ByteArrayInputStream(svcbb)), svcb.length(), svcbb); + assertEquals(expectedString, svcb.toString()); + } + @Test public void testARecordInvalidIp() throws Exception { assertThrows(IllegalArgumentException.class, () ->