diff --git a/README.md b/README.md index 5eb7d84..139e1f6 100644 --- a/README.md +++ b/README.md @@ -258,8 +258,25 @@ And an answer, additional, or authority looks like this udpPayloadSize: 4096, flags: packet.DNSSEC_OK, options: [{ + // pass in any code/data for generic EDNS0 options code: 12, data: Buffer.alloc(31) + }, { + // Several EDNS0 options have explicit support + code: 'padding', + length: 31 + }, { + code: 'CLIENT-SUBNET', + family: 2, // 1 for IPv4, 2 for IPv6 + sourcePrefixLength: 64, // used to truncate IP address + scopePrefixLength: 0, + ip: 'fe80::' + }, { + code: 'tcp-keepalive', + timeout: 150 // increments of 100ms. This means 15s. + }, { + code: 'key-tag', + tags: [1, 2, 3] }] } ``` diff --git a/index.js b/index.js index 2cbf6af..034a0b4 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ const types = require('./types') const rcodes = require('./rcodes') const opcodes = require('./opcodes') const classes = require('./classes') +const optioncodes = require('./optioncodes') const ip = require('ip') const QUERY_FLAG = 0 @@ -671,12 +672,68 @@ roption.encode = function (option, buf, offset) { if (!offset) offset = 0 const oldOffset = offset - buf.writeUInt16BE(option.code, offset) + const code = optioncodes.toCode(option.code) + buf.writeUInt16BE(code, offset) offset += 2 - buf.writeUInt16BE(option.data.length, offset) - offset += 2 - option.data.copy(buf, offset) - offset += option.data.length + if (option.data) { + buf.writeUInt16BE(option.data.length, offset) + offset += 2 + option.data.copy(buf, offset) + offset += option.data.length + } else { + switch (code) { + // case 3: NSID. No encode makes sense. + // case 5,6,7: Not implementable + case 8: // ECS + // note: do IP math before calling + const spl = option.sourcePrefixLength || 0 + const fam = option.family || (ip.isV4Format(option.ip) ? 1 : 2) + const ipBuf = ip.toBuffer(option.ip) + const ipLen = Math.ceil(spl / 8) + buf.writeUInt16BE(ipLen + 4, offset) + offset += 2 + buf.writeUInt16BE(fam, offset) + offset += 2 + buf.writeUInt8(spl, offset++) + buf.writeUInt8(option.scopePrefixLength || 0, offset++) + + ipBuf.copy(buf, offset, 0, ipLen) + offset += ipLen + break + // case 9: EXPIRE (experimental) + // case 10: COOKIE. No encode makes sense. + case 11: // KEEP-ALIVE + if (option.timeout) { + buf.writeUInt16BE(2, offset) + offset += 2 + buf.writeUInt16BE(option.timeout, offset) + offset += 2 + } else { + buf.writeUInt16BE(0, offset) + offset += 2 + } + break + case 12: // PADDING + const len = option.length || 0 + buf.writeUInt16BE(len, offset) + offset += 2 + buf.fill(0, offset, offset + len) + offset += len + break + // case 13: CHAIN. Experimental. + case 14: // KEY-TAG + const tagsLen = option.tags.length * 2 + buf.writeUInt16BE(tagsLen, offset) + offset += 2 + for (const tag of option.tags) { + buf.writeUInt16BE(tag, offset) + offset += 2 + } + break + default: + throw new Error(`Unknown roption code: ${option.code}`) + } + } roption.encode.bytes = offset - oldOffset return buf @@ -687,10 +744,38 @@ roption.encode.bytes = 0 roption.decode = function (buf, offset) { if (!offset) offset = 0 const option = {} - option.code = buf.readUInt16BE(offset) - const len = buf.readUInt16BE(offset + 2) - option.data = buf.slice(offset + 4, offset + 4 + len) + option.type = optioncodes.toString(option.code) + offset += 2 + const len = buf.readUInt16BE(offset) + offset += 2 + option.data = buf.slice(offset, offset + len) + switch (option.code) { + // case 3: NSID. No decode makes sense. + case 8: // ECS + option.family = buf.readUInt16BE(offset) + offset += 2 + option.sourcePrefixLength = buf.readUInt8(offset++) + option.scopePrefixLength = buf.readUInt8(offset++) + const padded = Buffer.alloc((option.family === 1) ? 4 : 16) + buf.copy(padded, 0, offset, offset + len - 4) + option.ip = ip.toString(padded) + break + // case 12: Padding. No decode makes sense. + case 11: // KEEP-ALIVE + if (len > 0) { + option.timeout = buf.readUInt16BE(offset) + offset += 2 + } + break + case 14: + option.tags = [] + for (let i = 0; i < len; i += 2) { + option.tags.push(buf.readUInt16BE(offset)) + offset += 2 + } + // don't worry about default. caller will use data if desired + } roption.decode.bytes = len + 4 return option @@ -699,7 +784,22 @@ roption.decode = function (buf, offset) { roption.decode.bytes = 0 roption.encodingLength = function (option) { - return option.data.length + 4 + if (option.data) { + return option.data.length + 4 + } + const code = optioncodes.toCode(option.code) + switch (code) { + case 8: // ECS + const spl = option.sourcePrefixLength || 0 + return Math.ceil(spl / 8) + 8 + case 11: // KEEP-ALIVE + return (typeof option.timeout === 'number') ? 6 : 4 + case 12: // PADDING + return option.length + 4 + case 14: // KEY-TAG + return 4 + (option.tags.length * 2) + } + throw new Error(`Unknown roption code: ${option.code}`) } const ropt = exports.opt = {} diff --git a/optioncodes.js b/optioncodes.js new file mode 100644 index 0000000..0d66e05 --- /dev/null +++ b/optioncodes.js @@ -0,0 +1,59 @@ +'use strict' + +exports.toString = function (type) { + switch (type) { + // list at + // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-11 + case 1: return 'LLQ' + case 2: return 'UL' + case 3: return 'NSID' + case 5: return 'DAU' + case 6: return 'DHU' + case 7: return 'N3U' + case 8: return 'CLIENT_SUBNET' + case 9: return 'EXPIRE' + case 10: return 'COOKIE' + case 11: return 'TCP_KEEPALIVE' + case 12: return 'PADDING' + case 13: return 'CHAIN' + case 14: return 'KEY_TAG' + case 26946: return 'DEVICEID' + } + if (type < 0) { + return null + } + return `OPTION_${type}` +} + +exports.toCode = function (name) { + if (typeof name === 'number') { + return name + } + if (!name) { + return -1 + } + switch (name.toUpperCase()) { + case 'OPTION_0': return 0 + case 'LLQ': return 1 + case 'UL': return 2 + case 'NSID': return 3 + case 'OPTION_4': return 4 + case 'DAU': return 5 + case 'DHU': return 6 + case 'N3U': return 7 + case 'CLIENT_SUBNET': return 8 + case 'EXPIRE': return 9 + case 'COOKIE': return 10 + case 'TCP_KEEPALIVE': return 11 + case 'PADDING': return 12 + case 'CHAIN': return 13 + case 'KEY_TAG': return 14 + case 'DEVICEID': return 26946 + case 'OPTION_65535': return 65535 + } + const m = name.match(/_(\d+)$/) + if (m) { + return parseInt(m[1], 10) + } + return -1 +} diff --git a/test.js b/test.js index 207d623..6a8d4d8 100644 --- a/test.js +++ b/test.js @@ -4,6 +4,7 @@ const tape = require('tape') const packet = require('./') const rcodes = require('./rcodes') const opcodes = require('./opcodes') +const optioncodes = require('./optioncodes') tape('unknown', function (t) { testEncoder(t, packet.unknown, Buffer.from('hello world')) @@ -352,10 +353,26 @@ tape('opt', function (t) { t.ok(compare(t, 0, additional2.flags), 'flags match') additional1.flags = packet.DNSSEC_OK additional1.extendedRcode = 0x80 - // padding, see RFC 7830 - additional1.options = [{ - code: 12, - data: Buffer.alloc(31) + additional1.options = [ { + code: 'CLIENT_SUBNET', // edns-client-subnet, see RFC 7871 + ip: 'fe80::', + sourcePrefixLength: 64 + }, { + code: 8, // still ECS + ip: '5.6.0.0', + sourcePrefixLength: 16, + scopePrefixLength: 16 + }, { + code: 'padding', + length: 31 + }, { + code: 'TCP_KEEPALIVE' + }, { + code: 'tcp_keepalive', + timeout: 150 + }, { + code: 'KEY_TAG', + tags: [1, 82, 987] }] buf = packet.encode(val) val2 = packet.decode(buf) @@ -363,7 +380,16 @@ tape('opt', function (t) { t.ok(compare(t, 1 << 15, additional2.flags), 'DO bit set in flags') t.ok(compare(t, true, additional2.flag_do), 'DO bit set') t.ok(compare(t, additional1.extendedRcode, additional2.extendedRcode), 'extended rcode matches') - t.ok(compare(t, additional1.options, additional2.options), 'options match') + t.ok(compare(t, 8, additional2.options[0].code)) + t.ok(compare(t, 'fe80::', additional2.options[0].ip)) + t.ok(compare(t, 64, additional2.options[0].sourcePrefixLength)) + t.ok(compare(t, '5.6.0.0', additional2.options[1].ip)) + t.ok(compare(t, 16, additional2.options[1].sourcePrefixLength)) + t.ok(compare(t, 16, additional2.options[1].scopePrefixLength)) + t.ok(compare(t, additional1.options[2].length, additional2.options[2].data.length)) + t.ok(compare(t, additional1.options[3].timeout, undefined)) + t.ok(compare(t, additional1.options[4].timeout, additional2.options[4].timeout)) + t.ok(compare(t, additional1.options[5].tags, additional2.options[5].tags)) t.end() }) @@ -509,6 +535,38 @@ tape('unpack', function (t) { t.end() }) +tape('optioncodes', function (t) { + const opts = [ + [0, 'OPTION_0'], + [1, 'LLQ'], + [2, 'UL'], + [3, 'NSID'], + [4, 'OPTION_4'], + [5, 'DAU'], + [6, 'DHU'], + [7, 'N3U'], + [8, 'CLIENT_SUBNET'], + [9, 'EXPIRE'], + [10, 'COOKIE'], + [11, 'TCP_KEEPALIVE'], + [12, 'PADDING'], + [13, 'CHAIN'], + [14, 'KEY_TAG'], + [26946, 'DEVICEID'], + [65535, 'OPTION_65535'], + [64000, 'OPTION_64000'], + [65002, 'OPTION_65002'], + [-1, null] + ] + for (const [code, str] of opts) { + const s = optioncodes.toString(code) + t.ok(compare(t, s, str), `${code} => ${str}`) + t.ok(compare(t, optioncodes.toCode(s), code), `${str} => ${code}`) + } + t.ok(compare(t, optioncodes.toCode('INVALIDINVALID'), -1)) + t.end() +}) + function testEncoder (t, rpacket, val) { const buf = rpacket.encode(val) const val2 = rpacket.decode(buf)