From a466357b0b6a69a8411ddd84903489bb44bfc939 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 24 Mar 2022 13:49:42 -0700 Subject: [PATCH] release 0.9.4 (#16) - add: getComment - zone_opts, for influencing output of toBind - normalize hostnames to lower case - add tests: fullyQualify, getFQDN - AAAA - compress: rewrote per RFC 5952, added tests - internally store address in expanded notation - fromTinydns: apply correct semantics for 'x' handling - fullyQualify - special handling for @ - consider $ORIGIN - add uc hex chars to ip6 compress --- CHANGES.md | 16 ++++++++++++++++ README.md | 19 ++++++++++++------ lib/tinydns.js | 2 +- package.json | 2 +- rr/aaaa.js | 51 ++++++++++++++++++++++++++++++++++++------------- rr/index.js | 47 ++++++++++++++++++++++++++++++++++----------- rr/mx.js | 6 +++--- rr/ns.js | 8 ++++++-- rr/ptr.js | 2 +- rr/soa.js | 33 ++++++++++++++++---------------- test/aaaa.js | 29 +++++++++++++++++++++++++++- test/index.js | 25 ++++++++++++++++++++++++ test/loc.js | 6 +++--- test/soa.js | 12 ++++++------ test/tinydns.js | 6 ++++++ 15 files changed, 199 insertions(+), 65 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8d2f9c4..b87b98c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,22 @@ #### 1.N.N - YYYY-MM-DD +#### 0.9.4 - 2022-03-24 + +- add: getComment +- zone_opts, for influencing output of toBind +- normalize hostnames to lower case +- add tests: fullyQualify, getFQDN +- AAAA + - compress: rewrote per RFC 5952, added tests + - internally store address in expanded notation +- fromTinydns: apply correct semantics for 'x' handling +- fullyQualify + - special handling for @ + - consider $ORIGIN +- add uc hex chars to ip6 compress + + #### 0.9.3 - 2022-03-22 - hasValidLabels: remove trailing dot, else split returns empty string diff --git a/README.md b/README.md index df9204c..3089367 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,7 @@ try { } } catch (e) { - // invalid RRs will throw - console.error(e) + console.error(e.message) // invalid RRs throw } ``` @@ -100,11 +99,18 @@ console.log(validatedA.toBind()) test.example.com 3600 IN A 192.0.2.128 ``` -The setter functions are named: `set` + `Field`, where field is the resource record field name to modify. Multi-word names are camel cased, so a field named `Certificate Usage` would have a setter named `setCertificateUsage`. You can get the field names with `getFields()`: +The setter functions are named: `set` + `Field`, where field is the resource record field name to modify. Multi-word names are camel cased, so a field named `Certificate Usage` would have a setter named `setCertificateUsage`. You can get the field names for each RR type with `getFields()`: ```js -> validatedA.getFields() +> const RR = require('dns-resource-record') +> new RR.A(null).getFields() [ 'name', 'ttl', 'class', 'type', 'address' ] + +> new RR.PTR(null).getFields() +[ 'name', 'ttl', 'class', 'type', 'dname' ] + +> new RR.SSHFP(null).getFields() +[ 'name', 'ttl', 'class', 'type', 'algorithm', 'fptype', 'fingerprint' ] ``` ## FUNCTIONS @@ -193,10 +199,10 @@ PRs are welcome, especially PRs with tests. ## TIPS -- Domain names are stored fully qualified, absolute, with the trailing dot. +- Domain names are stored fully qualified, aka absolute. - Master Zone File expansions exist at another level - fromBIND is regex based and is naive. [dns-zone-validator](https://github.com/msimerson/dns-zone-validator) has a much more robust parser. -- +- toBind output (suppress TTL, class, relative domain names) can be influenced by passing in an options object. See it in `bin/import.js` in the [dns-zone-validator](https://github.com/msimerson/dns-zone-validator) package. ## TODO @@ -207,3 +213,4 @@ PRs are welcome, especially PRs with tests. - [x] add defaults for empty values like TTL - [x] DNSSEC RRs, except: RRSIG, NSEC, NSEC3, NSEC3PARAM - [ ] Additional RRs?: KX, CERT, DHCID, TLSA, ... +- [ ] add toWire, exports in wire/network format diff --git a/lib/tinydns.js b/lib/tinydns.js index 914362f..ab8d14d 100644 --- a/lib/tinydns.js +++ b/lib/tinydns.js @@ -1,7 +1,7 @@ const SPECIALCHARS = { '+': [ 'A' ], - '-': [ undefined ], + '-': [ undefined ], // disabled RR '%': [ 'location' ], '.': [ 'SOA', 'NS', 'A' ], '&': [ 'NS', 'A' ], diff --git a/package.json b/package.json index 2dacd87..f197032 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dns-resource-record", - "version": "0.9.3", + "version": "0.9.4", "description": "DNS Resource Records", "main": "rr/index.js", "scripts": { diff --git a/rr/aaaa.js b/rr/aaaa.js index 224fb6a..f461978 100644 --- a/rr/aaaa.js +++ b/rr/aaaa.js @@ -12,9 +12,9 @@ class AAAA extends RR { /****** Resource record specific setters *******/ setAddress (val) { if (!val) throw new Error('AAAA: address is required') - if (!net.isIPv6(val)) throw new Error('AAAA: address must be IPv6') + if (!net.isIPv6(val)) throw new Error(`AAAA: address must be IPv6 (${val})`) - this.set('address', val.toLowerCase()) // IETFs suggest only lower case + this.set('address', this.expand(val.toLowerCase())) // lower case: RFC 5952 } getCompressed (val) { @@ -45,14 +45,13 @@ class AAAA extends RR { case ':': // GENERIC => :fqdn:28:rdata:ttl:timestamp:lo [ fqdn, n, rdata, ttl, ts, loc ] = str.substring(1).split(':') - if (n != 28) throw new Error('SRV fromTinydns, invalid n') - // compressed format is needed for test comparisons - ip = this.compress(TINYDNS.octalToHex(rdata).match(/([0-9a-fA-F]{4})/g).join(':')) + if (n != 28) throw new Error('AAAA fromTinydns, invalid n') + ip = TINYDNS.octalToHex(rdata).match(/([0-9a-fA-F]{4})/g).join(':') break case '3': case '6': - // AAAA => 3 fqdn:rdata:x:ttl:timestamp:lo - // AAAA,PTR => 6 fqdn:rdata:x:ttl:timestamp:lo + // AAAA => 3fqdn:ip:x:ttl:timestamp:lo + // AAAA,PTR => 6fqdn:ip:x:ttl:timestamp:lo [ fqdn, rdata, ttl, ts, loc ] = str.substring(1).split(':') ip = rdata.match(/(.{4})/g).join(':') break @@ -75,17 +74,40 @@ class AAAA extends RR { class : c, type : type, name : fqdn, - address: ip, + address: this.expand(ip), ttl : parseInt(ttl, 10), }) } compress (val) { - return val - .replace(/\b(?:0+:){2,}/, ':') // compress all zero - .split(':') - .map(o => o.replace(/\b0+/g, '')) // strip leading zero - .join(':') + /* + * RFC 5952 + * 4.1. Leading zeros MUST be suppressed...A single 16-bit 0000 field MUST be represented as 0. + * 4.2.1 The use of the symbol "::" MUST be used to its maximum capability. + * 4.2.2 The symbol "::" MUST NOT be used to shorten just one 16-bit 0 field. + * 4.2.3 When choosing placement of a "::", the longest run...MUST be shortened + * 4.3 The characters a-f in an IPv6 address MUST be represented in lowercase. + */ + let r = val + .replace(/0000/g, '0') // 4.1 0000 -> 0 + .replace(/:0+([1-9a-fA-F])/g, ':$1') // 4.1 remove leading zeros + + const mostConsecutiveZeros = [ + new RegExp(/0?(?::0){6,}:0?/), + new RegExp(/0?(?::0){5,}:0?/), + new RegExp(/0?(?::0){4,}:0?/), + new RegExp(/0?(?::0){3,}:0?/), + new RegExp(/0?(?::0){2,}:0?/), + ] + + for (const re of mostConsecutiveZeros) { + if (re.test(r)) { + r = r.replace(re, '::') + break + } + } + + return r } expand (val, delimiter) { @@ -102,6 +124,9 @@ class AAAA extends RR { } /****** EXPORTERS *******/ + toBind (zone_opts) { + return `${this.getPrefix(zone_opts)}\t${this.compress(this.get('address'))}\n` + } toTinydns () { // from AAAA notation (8 groups of 4 hex digits) to 16 escaped octals diff --git a/rr/index.js b/rr/index.js index 6e5369f..9c5a378 100644 --- a/rr/index.js +++ b/rr/index.js @@ -26,6 +26,8 @@ class RR extends Map { if (this[fnName] === undefined) throw new Error(`Missing ${fnName} in class ${this.get('type')}`) this[fnName](opts[f]) } + + if (opts.comment) this.set('comment', opts.comment) } ucfirst (str) { @@ -84,7 +86,7 @@ class RR extends Map { if (!/^\*\./.test(n) && !/\.\*\./.test(n)) throw new Error('only *.something or * (by itself) is a valid wildcard') } - this.set('name', n) + this.set('name', n.toLowerCase()) } setTtl (t) { @@ -110,13 +112,21 @@ class RR extends Map { this.set('type', t) } - fullyQualify (str) { - if (str.endsWith('.')) return str - return `${str}.` + fullyQualify (hostname, origin) { + if (!hostname) return hostname + if (hostname === '@' && origin) hostname = origin + if (hostname.endsWith('.')) return hostname.toLowerCase() + if (origin) return `${hostname}.${origin}`.toLowerCase() + return `${hostname}.` } - getPrefix () { - return `${this.getFQDN('name')}\t${this.get('ttl')}\t${this.get('class')}\t${this.get('type')}` + getPrefix (zone_opts = {}) { + const classVal = zone_opts.hide?.class ? '' : this.get('class') + + let rrTTL = this.get('ttl') + if (zone_opts.hide?.ttl && rrTTL === zone_opts.ttl) rrTTL = '' + + return `${this.getFQDN('name', zone_opts)}\t${rrTTL}\t${classVal}\t${this.get('type')}` } getPrefixFields () { @@ -129,6 +139,12 @@ class RR extends Map { return this.get(prop) === undefined ? '' : this.get(prop) } + getComment (prop) { + const c = this.get('comment') + if (!c || !c[prop]) return '' + return c[prop] + } + getQuoted (prop) { // if prop is not in quoted list, return bare if (!this.getQuotedFields().includes(prop)) return this.get(prop) @@ -158,9 +174,17 @@ class RR extends Map { } } - getFQDN (field) { - if (this.get(field).endsWith('.')) return this.get(field) - return `${this.get(field)}.` + getFQDN (field, zone_opts = {}) { + let fqdn = this.get(field) + if (!fqdn) throw new Error(`empty value for field ${field}`) + if (!fqdn.endsWith('.')) fqdn += '.' + + if (zone_opts.hide?.origin && zone_opts.origin) { + if (fqdn === zone_opts.origin) return '@' + if (fqdn.endsWith(zone_opts.origin)) return fqdn.slice(0, fqdn.length - zone_opts.origin.length - 1) + } + + return fqdn } getTinyFQDN (field) { @@ -236,13 +260,14 @@ class RR extends Map { throw new Error(`${type}, ${field} has invalid hostname characters`) } - toBind () { - return `${this.getPrefix()}\t${this.getRdataFields().map(f => this.getQuoted(f)).join('\t')}\n` + toBind (zone_opts) { + return `${this.getPrefix(zone_opts)}\t${this.getRdataFields().map(f => this.getQuoted(f)).join('\t')}\n` } } module.exports = { RR, + TINYDNS: require('../lib/tinydns'), } const files = fs.readdirSync(path.join(__dirname)) diff --git a/rr/mx.js b/rr/mx.js index 154b11f..9ffd441 100644 --- a/rr/mx.js +++ b/rr/mx.js @@ -52,7 +52,7 @@ class MX extends RR { return new this.constructor({ type : 'MX', name : this.fullyQualify(fqdn), - exchange : this.fullyQualify(x), + exchange : this.fullyQualify(/\./.test(x) ? x : `${x}.mx.${fqdn}`), preference: parseInt(preference, 10) || 0, ttl : parseInt(ttl, 10), timestamp : ts, @@ -75,8 +75,8 @@ class MX extends RR { } /****** EXPORTERS *******/ - toBind () { - return `${this.getPrefix()}\t${this.get('preference')}\t${this.getFQDN('exchange')}\n` + toBind (zone_opts) { + return `${this.getPrefix(zone_opts)}\t${this.get('preference')}\t${this.getFQDN('exchange', zone_opts)}\n` } toTinydns () { diff --git a/rr/ns.js b/rr/ns.js index dd9370c..b61e769 100644 --- a/rr/ns.js +++ b/rr/ns.js @@ -14,7 +14,7 @@ class NS extends RR { this.isFullyQualified('NS', 'dname', val) this.isValidHostname('NS', 'dname', val) - this.set('dname', val) + this.set('dname', val.toLowerCase()) } getDescription () { @@ -42,7 +42,7 @@ class NS extends RR { return new this.constructor({ type : 'NS', name : this.fullyQualify(fqdn), - dname : this.fullyQualify(dname), + dname : this.fullyQualify(/\./.test(dname) ? dname : `${dname}.ns.${fqdn}`), ttl : parseInt(ttl, 10), timestamp: ts, location : loc !== '' && loc !== '\n' ? loc : '', @@ -63,6 +63,10 @@ class NS extends RR { } /****** EXPORTERS *******/ + toBind (zone_opts) { + return `${this.getPrefix(zone_opts)}\t${this.getFQDN('dname', zone_opts)}\n` + } + toTinydns () { return `&${this.getTinyFQDN('name')}::${this.getTinyFQDN('dname')}:${this.getTinydnsPostamble()}\n` } diff --git a/rr/ptr.js b/rr/ptr.js index df54bed..c43dc48 100644 --- a/rr/ptr.js +++ b/rr/ptr.js @@ -11,7 +11,7 @@ class PTR extends RR { this.isFullyQualified('PTR', 'dname', val) this.isValidHostname('PTR', 'dname', val) - this.set('dname', val) + this.set('dname', val.toLowerCase()) } getDescription () { diff --git a/rr/soa.js b/rr/soa.js index 2c7a826..291bb86 100644 --- a/rr/soa.js +++ b/rr/soa.js @@ -20,7 +20,8 @@ class SOA extends RR { // MNAME (primary NS) this.isValidHostname('SOA', 'MNAME', val) this.isFullyQualified('SOA', 'MNAME', val) - this.set('mname', val) + + this.set('mname', val.toLowerCase()) } setRname (val) { @@ -28,7 +29,8 @@ class SOA extends RR { this.isValidHostname('SOA', 'RNAME', val) this.isFullyQualified('SOA', 'RNAME', val) if (/@/.test(val)) throw new Error(`SOA rname replaces @ with a . (dot), ${this.getRFCs()}`) - this.set('rname', val) + + this.set('rname', val.toLowerCase()) } setSerial (val) { @@ -115,28 +117,25 @@ class SOA extends RR { name : fqdn, mname : mname, rname : rname, - serial : parseInt(serial, 10), + serial : parseInt(serial , 10), refresh: parseInt(refresh, 10), - retry : parseInt(retry, 10), - expire : parseInt(expire, 10), - minimum: parseInt(minimum, 10 ), - ttl : parseInt(ttl, 10), + retry : parseInt(retry , 10), + expire : parseInt(expire , 10), + minimum: parseInt(minimum, 10), + ttl : parseInt(ttl , 10), } // console.log(bits) return new this.constructor(bits) } /****** EXPORTERS *******/ - toBind () { - return `$TTL\t${this.get('ttl')} -$ORIGIN\t${this.getFQDN('name')} -${this.getFQDN('name')}\t${this.get('class')}\tSOA\t${this.getFQDN('mname')}\t${this.getFQDN('rname')} ( - ${this.get('serial')} - ${this.get('refresh')} - ${this.get('retry')} - ${this.get('expire')} - ${this.get('minimum')} - )\n\n` + toBind (zone_opts) { + const numFields = [ 'serial', 'refresh', 'retry', 'expire', 'minimum' ] + return `$TTL\t${this.get('ttl')}${this.getComment('ttl')} +$ORIGIN\t${this.getFQDN('name')}${this.getComment('origin')} +${this.getFQDN('name', zone_opts)}\t${this.get('class')}\tSOA\t${this.getFQDN('mname', zone_opts)}\t${this.getFQDN('rname', zone_opts)} ( +${numFields.map(f => '\t\t' + this.get(f) + this.getComment(f) + '\n').join('')}\t\t) +\n` } toTinydns () { diff --git a/test/aaaa.js b/test/aaaa.js index 56cd38d..c7a8b83 100644 --- a/test/aaaa.js +++ b/test/aaaa.js @@ -9,7 +9,7 @@ const validRecords = [ class : 'IN', name : 'test.example.com.', type : 'AAAA', - address: '2001:db8:20:a::4', + address: '2001:0db8:0020:000a:0000:0000:0000:0004', ttl : 3600, testB : 'test.example.com.\t3600\tIN\tAAAA\t2001:db8:20:a::4\n', testT : ':test.example.com:28:\\040\\001\\015\\270\\000\\040\\000\\012\\000\\000\\000\\000\\000\\000\\000\\004:3600::\n', @@ -50,4 +50,31 @@ describe('AAAA record', function () { } }) } + + const tests = [ + { e: '2001:0db8:0020:000a:0000:0000:0000:0004', c: '2001:db8:20:a::4' }, + { e: '0000:0000:0000:0000:0000:0000:0000:0000', c: '::' }, + { e: '0000:0000:0000:0000:0000:0000:0000:0001', c: '::1' }, + { e: '2001:0db8:0000:0000:0000:0000:0002:0001', c: '2001:db8::2:1' }, + { e: '2001:0db8:0000:0001:0001:0001:0001:0001', c: '2001:db8:0:1:1:1:1:1' }, + { e: '2001:0DB8:0000:0000:0008:0800:200C:417A', c: '2001:DB8::8:800:200C:417A' }, + ] + + describe('compress', function () { + const r = new AAAA(null) + for (const t of tests) { + it(`compresses IPv6 address (${t.e})`, function () { + assert.equal(r.compress(t.e), t.c) + }) + } + }) + + describe('expand', function () { + const r = new AAAA(null) + for (const t of tests) { + it(`expands IPv6 address (${t.c})`, function () { + assert.equal(r.expand(t.c), t.e) + }) + } + }) }) \ No newline at end of file diff --git a/test/index.js b/test/index.js index 34b4f2b..9efcd2e 100644 --- a/test/index.js +++ b/test/index.js @@ -41,6 +41,16 @@ describe('RR', function () { } }) + describe('fullyQualify', function () { + it('does nothing to empty hostname', async () => { + assert.equal(r.fullyQualify(''), '') + }) + + it('fully qualifies a valid hostname', async () => { + assert.equal(r.fullyQualify('example.com'), 'example.com.') + }) + }) + describe('getFields', function () { it('gets common fields', async function () { assert.deepStrictEqual(r.getFields('common'), [ 'name', 'ttl', 'class', 'type' ]) @@ -51,6 +61,21 @@ describe('RR', function () { }) }) + describe('getFQDN', function () { + it('adds a period to hostnames', async () => { + const rr = new RR(null) + rr.set('name', 'www.example.com') // bypass FQ check + assert.equal(rr.getFQDN('name'), 'www.example.com.') + }) + + it('reduces origin on request', async () => { + const rr = new RR(null) + const zone_opts = { origin: 'example.com.', hide: { origin: true } } + rr.setName('www.example.com.') + assert.equal(rr.getFQDN('name', zone_opts), 'www') + }) + }) + describe('isFullyQualified', function () { it('should detect FQDNs', async function () { assert.deepEqual(r.isFullyQualified('$type', '$field', 'host.example.com.'), true) diff --git a/test/loc.js b/test/loc.js index b9a1ba1..6e5aac0 100644 --- a/test/loc.js +++ b/test/loc.js @@ -25,12 +25,12 @@ const validRecords = [ testT : ':cambridge-net.kei.com:29:\\000\\063\\000\\000\\211\\027\\055\\320\\160\\276\\025\\360\\000\\230\\215\\040:3600::\n', }, { - name : 'rwy04L.logan-airport.boston.', + name : 'rwy04l.logan-airport.boston.', type : 'LOC', address: '42 21 28.764 N 71 0 51.617 W -44m 2000m', ttl : 3600, - testB : 'rwy04L.logan-airport.boston.\t3600\tIN\tLOC\t42 21 28.764 N 71 0 51.617 W -44m 2000m\n', - testT : ':rwy04L.logan-airport.boston:29:\\000\\045\\000\\000\\211\\026\\313\\074\\160\\303\\020\\337\\000\\230\\205\\120:3600::\n', + testB : 'rwy04l.logan-airport.boston.\t3600\tIN\tLOC\t42 21 28.764 N 71 0 51.617 W -44m 2000m\n', + testT : ':rwy04l.logan-airport.boston:29:\\000\\045\\000\\000\\211\\026\\313\\074\\160\\303\\020\\337\\000\\230\\205\\120:3600::\n', }, ] diff --git a/test/soa.js b/test/soa.js index 549fa47..e55e658 100644 --- a/test/soa.js +++ b/test/soa.js @@ -21,12 +21,12 @@ const validRecords = [ testB : `$TTL\t3600 $ORIGIN\texample.com. example.com.\tIN\tSOA\tns1.example.com.\tmatt.example.com. ( - 1 - 7200 - 3600 - 1209600 - 3600 - )\n\n`, +\t\t1 +\t\t7200 +\t\t3600 +\t\t1209600 +\t\t3600 +\t\t)\n\n`, testT: 'Zexample.com:ns1.example.com:matt.example.com:1:7200:3600:1209600:3600:3600::\n', }, ] diff --git a/test/tinydns.js b/test/tinydns.js index a565613..4857f28 100644 --- a/test/tinydns.js +++ b/test/tinydns.js @@ -27,6 +27,12 @@ describe('TINYDNS', function () { } }) + describe('octalToHex', function () { + it('unescapes octal to hex digits', function () { + assert.strictEqual(TINYDNS.octalToHex('\\000\\000\\000\\000\\000\\000\\000\\000\\000\\000\\000\\000\\000\\000\\000\\001'), '00000000000000000000000000000001') + }) + }) + describe('UInt16toOctal', function () { it('converts a 16-bit number to escaped octal', function () { assert.strictEqual(TINYDNS.UInt16toOctal(65535), '\\377\\377')