From 0ac19a7503db31b59d10d1ab4867d2a93b2260f8 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Tue, 22 Mar 2022 19:15:42 -0700 Subject: [PATCH] release 0.2.0 (#2) - add: expandShortcuts - add: bin/import - use async for parseZoneFile and expandShortcuts - SOA: capture comments --- .github/workflows/ci-test.yml | 2 +- .github/workflows/coveralls.yml | 2 +- CHANGES.md | 23 ++++++ README.md | 20 ++++- bin/import.js | 69 ++++++++++++++++ index.js | 95 ++++++++++++++++++++- package.json | 10 ++- src/bind-grammar.ne | 141 +++++++++++++++---------------- test/index.js | 142 ++++++++++++++++++++++++++++---- 9 files changed, 406 insertions(+), 98 deletions(-) create mode 100644 CHANGES.md create mode 100755 bin/import.js diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 8775839..44bc461 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -1,5 +1,5 @@ -name: Module Tests +name: Tests on: [ push ] diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml index 7ef60da..0cf92f9 100644 --- a/.github/workflows/coveralls.yml +++ b/.github/workflows/coveralls.yml @@ -1,7 +1,7 @@ on: [ pull_request ] -name: Test Coverage +name: Coverage jobs: diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..cfbe85b --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,23 @@ + +#### 1.N.N - YYYY-MM-DD + + +#### 0.2.0 - 2022-03-22 + +- add expandShortcuts +- added bin/import +- use async for parseZoneFile and expandShortcuts +- SOA: capture comments + + +#### 0.1.0 - 2022-03-17 + +- Bind zonefile parser, using nearley: #1 + +- stab #1: parses cadillac.net +- test #2: add isi.edu zone +- allow comments after SOA closing parens +- DRY the grammar -> object functions +- add \r to eol, for windows +- local copy of builtin-whitespace, adds \r char +- ci: remove windows support, times out, I think upstream nearley issue \ No newline at end of file diff --git a/README.md b/README.md index 4cfd5c6..26c0c56 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,24 @@ DNS zones have numerous rules regarding the records that can exist in them. Exam - serial numbers must increment when changes are made - multiple identical RRs are not allowed - RFC 2181 + - CAA takes tag into account, SRV: port - multiple CNAMES with the same name are not allowed -- CNAME cannot coexist with SIG,NXT,KEY,RRSIG,NSEC -- A cannot coexist with CNAME +- CNAME cannot coexist with SIG,NXT,KEY,RRSIG,NSEC,A,AAAA +- MX and NS records cannot point to CNAME Etc, etc, etc.. -This module will input a collection of [dns-resource-records](https://github.com/msimerson/dns-resource-record) and validate that all the zone records can coexist. \ No newline at end of file +This module will input a collection of [dns-resource-records](https://github.com/msimerson/dns-resource-record) and validate that all the zone records can coexist. + + +## TODO + +- [ ] write a named.conf file parser +- [x] write a bind zone file parser +- normalize the zone records + - [x] expand `@` to zone name + - [x] empty names are same as previous RR record + - [x] missing TTLs inherit zone TTL, or zone MINIMUM + - [x] expand hostnames to FQDNs + - [x] ALL: name field + - [x] MX: exchange, CNAME: cname, SOA: mname, rname, NS: dname \ No newline at end of file diff --git a/bin/import.js b/bin/import.js new file mode 100755 index 0000000..b8ffb87 --- /dev/null +++ b/bin/import.js @@ -0,0 +1,69 @@ +#!node + +const fs = require('fs') +const path = require('path') +const os = require('os') + +const dz = require('../index') + +const filePath = process.argv[2] +if (!filePath) usage() + +function usage () { + console.log(`\n ${process.argv[1]} file\n`) + process.exit(1) +} + +console.log(`reading file ${filePath}`) + +fs.readFile(filePath, (err, buf) => { + if (err) throw err + + const base = path.basename(filePath) + const asString = fileAsString(buf, base) + + dz.parseZoneFile(asString) + .then(dz.expandShortcuts) + .then(zoneArray => { + // console.log(zoneArray) + switch (process.argv[3]) { + case 'toBind': + toBind(zoneArray, base) + break + case 'toTinydns': + toTinydns(zoneArray) + break + default: + console.log(zoneArray) + } + }) + .catch(e => { + console.error(e.message) + }) +}) + +function fileAsString (buf, base) { + let str = buf.toString() + + if (!/^\$ORIGIN/m.test(str)) { + console.log(`inserting $ORIGIN ${base}`) + str = `$ORIGIN ${base}.${os.EOL}${str}` + } + // console.log(str) + return str +} + +function toBind (zoneArray, origin) { + for (const rr of zoneArray) { + let out = rr.toBind() + const reduceRE = new RegExp(`^([^\\s]+).${origin}.`) + out = out.replace(reduceRE, '$1') + process.stdout.write(out) + } +} + +function toTinydns (zoneArray) { + for (const rr of zoneArray) { + process.stdout.write(rr.toTinydns()) + } +} \ No newline at end of file diff --git a/index.js b/index.js index 57488b7..a278d56 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,15 @@ const nearley = require('nearley') -const grammar = require('./grammar.js') +const RR = require('dns-resource-record') + +const grammar = nearley.Grammar.fromCompiled(require('./grammar.js')) grammar.start = 'main' -exports.parseZoneFile = str => { +exports.parseZoneFile = async str => { - const parser = new nearley.Parser(nearley.Grammar.fromCompiled(grammar)) + const parser = new nearley.Parser(grammar) parser.feed(str) - parser.feed(`\n`) // for no EOL after last record + parser.feed(`\n`) // in case no EOL after last record if (parser.length > 1) { console.error(`ERROR: ambigious parser rule`) @@ -24,3 +26,88 @@ exports.parseZoneFile = str => { } return flat } + +exports.expandShortcuts = async zoneArray => { + let ttl = 0 + let implicitOrigin = '' + let origin = '' + let lastName = '' + const expanded = [] + const empty = [ undefined, null ] + // console.log(zoneArray) + + for (let i = 0; i < zoneArray.length; i++) { + const entry = zoneArray[i] + + if (entry.$TTL) { + ttl = entry.$TTL; continue + } + + // When a zone is first read, there is an implicit $ORIGIN . + // note the trailing dot. The current $ORIGIN is appended to the domain + // specified in the $ORIGIN argument if it is not absolute. -- BIND 9 + if (entry.implicitOrigin) { // zone 'name' in named.conf + implicitOrigin = origin = fullyQualify(entry.implicitOrigin) + continue + } + if (entry.$ORIGIN) { // declared $ORIGIN within zone file + origin = fullyQualify(entry.$ORIGIN, implicitOrigin) + continue + } + if (!origin) throw new Error(`zone origin ambiguous, cowardly bailing out`) + + if (ttl === 0 && entry.type === 'SOA' && entry.minimum) ttl = entry.minimum + if (empty.includes(entry.ttl )) entry.ttl = ttl + if (empty.includes(entry.class)) entry.class = 'IN' + + // expand NAME shortcuts + if (entry.name === '@') entry.name = origin + + // "If a line begins with a blank, then the owner is assumed to be the + // same as that of the previous RR" -- BIND 9 manual + if (entry.name === '' && lastName) entry.name = lastName + + if (entry.name) { + entry.name = fullyQualify(entry.name, origin) + } + else { + entry.name = `${origin}`.toLowerCase() + } + + if (entry.name !== lastName) lastName = entry.name + + expandRdata(entry, origin, ttl) + + try { + expanded.push(new RR[entry.type](entry)) + } + catch (e) { + console.error(`I encounted this error: \n`) + console.error(e.message) + console.error(`\nwhile processing this RR: \n`) + console.log(entry) + } + } + return expanded +} + +function fullyQualify (hostname, origin) { + if (hostname.endsWith('.')) return hostname + return `${hostname}.${origin}`.toLowerCase() +} + +function expandRdata (entry, origin, ttl) { + switch (entry.type) { + case 'SOA': + for (const f of [ 'mname', 'rname' ]) { + entry[f] = fullyQualify(entry[f], origin) + } + break + case 'MX': + entry.exchange = fullyQualify(entry.exchange, origin) + break + case 'NS': + entry.dname = fullyQualify(entry.dname, origin) + break + } +} \ No newline at end of file diff --git a/package.json b/package.json index 6ba9c86..5dd8607 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dns-zone-validator", - "version": "0.1.0", + "version": "0.2.0", "description": "DNS Zone Validator", "main": "index.js", "scripts": { @@ -9,7 +9,8 @@ "lint": "npx eslint index.js test/*.js", "lintfix": "npx eslint --fix index.js test/*.js", "postinstall": "npx -p nearley nearleyc src/bind-grammar.ne -o grammar.js", - "test": "npx mocha" + "test": "npx mocha", + "versions": "npx dependency-version-checker check" }, "repository": { "type": "git", @@ -28,10 +29,11 @@ }, "homepage": "https://github.com/msimerson/dns-zone-validator#readme", "devDependencies": { - "eslint": "^8.0.1", - "mocha": "^9.1.3" + "eslint": "^8.11.0", + "mocha": "^9.2.2" }, "dependencies": { + "dns-resource-record": "^0.9.3", "nearley": "^2.20.1" } } diff --git a/src/bind-grammar.ne b/src/bind-grammar.ne index 0572101..fb55da5 100644 --- a/src/bind-grammar.ne +++ b/src/bind-grammar.ne @@ -1,3 +1,5 @@ +# This is a parser, not a validator. Don't go crazy with rules here, +# we validate after parsing @builtin "string.ne" @@ -21,11 +23,11 @@ soa -> hostname ( __ uint ):? ( __ class ):? __ "SOA" __ hostname __ hostname __ "(" - __ uint (_ comment):? - __ uint (_ comment):? - __ uint (_ comment):? - __ uint (_ comment):? - __ uint (_ comment):? + _ uint (ws comment):? + __ uint (ws comment):? + __ uint (ws comment):? + __ uint (ws comment):? + __ uint (ws comment):? _ ")" _ (comment):? {% (d) => toResourceRecord(d) %} @@ -63,7 +65,10 @@ hostname -> ALPHA_NUM_DASH_U:* {% (d) => d[0].join("") %} ALPHA_NUM_DASH_U -> [-0-9A-Za-z\u0080-\uFFFF._@] {% id %} -class -> "IN" | "CH" | "HS" | "CHAOS" | "ANY" +class -> "IN" | "CS" | "CH" | "HS" | "NONE" | "ANY" + +#not_whitespace -> [^\n\r] {% id %} +#host_chars -> [-0-9A-Za-z\u0080-\uFFFF._@/] {% id %} times_3[X] -> $X $X $X times_5[X] -> $X $X $X $X $X @@ -71,6 +76,10 @@ times_7[X] -> $X $X $X $X $X $X $X ip4 -> Snum times_3["." Snum] {% (d) => flat_string(d) %} +#ip4 -> ([0-9.]):+ {% (d) => flat_string(d) %} +#ip6 -> (ip6_chars):+ {% (d) => flat_string(d) %} +#ip6_chars -> [0-9A-Fa-f:.] {% id %} + ip6 -> IPv6_full | IPv6_comp | IPv6v4_full | IPv6v4_comp Snum -> DIGIT | @@ -105,83 +114,75 @@ IPv6v4_comp -> (IPv6_hex times_3[":" IPv6_hex]):? "::" # Whitespace: `_` is optional, `__` is mandatory. _ -> wschar:* {% function(d) {return null;} %} __ -> wschar:+ {% function(d) {return null;} %} +ws -> wschar:* {% id %} wschar -> [ \t\n\r\v\f] {% id %} -#ALPHA_NUM -> [0-9A-Za-z] -#ALPHA_NUM_U -> [0-9A-Za-z\u0080-\uFFFF] {% id %} - - -# https://datatracker.ietf.org/doc/html/rfc1035#page-12 -#domain -> subdomain | " " -#subdomain -> label | subdomain "." label -#label -> letter ldh-str let-dig -#ldh-str -> let-dig-hyp | let-dig-hyp ldh-str -#let-dig-hyp -> let-dig | "-" -#let-dig -> letter | digit -#letter -> [a-zA-Z] -#digit -> [0-9] - - @{% function flat_string(d) { - if (d) { - if (Array.isArray(d)) return d.flat(Infinity).join("") - return d - } - return '' + if (!d) return '' + if (Array.isArray(d)) return d.flat(Infinity).join('') + return d } function ttlAsObject (d) { - return { ttl: d[2] } + return { $TTL: d[2] } } function originAsObject (d) { - return { origin: d[2] } + return { $ORIGIN: d[2] } } function toResourceRecord (d) { - const r = { - name: d[0], - ttl : d[1] ? d[1][1] : d[1], - class: d[2] ? d[2][1][0] : d[2], - type: d[4], - } - - switch (r.type) { - case 'A': - r.address = d[6] - break - case 'AAAA': - r.address = d[6][0] - break - case 'CNAME': - r.cname = d[6][0] - break - case 'DNAME': - r.target = d[6][0] - break - case 'MX': - r.preference = d[6] - r.exchange = d[8] - break - case 'NS': - r.dname = d[6] - break - case 'SOA': - r.mname = d[6] - r.rname = d[8] - r.serial = d[12] - r.refresh = d[15] - r.retry = d[18] - r.expire = d[21] - r.minimum = d[24] - break - case 'TXT': - r.data = d[6].map(e => e[0]) - break - } - return r + const r = { + name: d[0], + ttl : d[1] ? d[1][1] : d[1], + class: d[2] ? d[2][1][0] : d[2], + type: d[4], + } + + switch (r.type) { + case 'A': + r.address = d[6] + break + case 'AAAA': + r.address = d[6][0] + break + case 'CNAME': + r.cname = d[6] + break + case 'DNAME': + r.target = d[6] + break + case 'MX': + r.preference = d[6] + r.exchange = d[8] + break + case 'NS': + r.dname = d[6] + break + case 'SOA': + r.comment = {} + r.mname = d[6] + r.rname = d[8] + r.serial = d[12] + r.comment.serial = flat_string(d[13]) + r.refresh = d[15] + r.comment.refresh = flat_string(d[16]) + r.retry = d[18] + r.comment.retry = flat_string(d[19]) + r.expire = d[21] + r.comment.expire = flat_string(d[22]) + r.minimum = d[24] + r.comment.minimum = flat_string(d[25]) + break + case 'TXT': + r.data = d[6].map(e => e[0]) + break + default: + throw new Error(`undefined type: ${r.type}`) + } + return r } -%} \ No newline at end of file +%} diff --git a/test/index.js b/test/index.js index ab80dfb..2f203d2 100644 --- a/test/index.js +++ b/test/index.js @@ -2,36 +2,37 @@ const assert = require('assert') const fs = require('fs') +const RR = require('dns-resource-record') const zv = require('../index') describe('parseZoneFile', function () { it('parses a blank line', async () => { - const r = zv.parseZoneFile(`\n`) + const r = await zv.parseZoneFile(`\n`) // console.dir(r, { depth: null }) assert.deepStrictEqual(r, []) }) it('parses two blank lines', async () => { - const r = zv.parseZoneFile(`\n\n`) + const r = await zv.parseZoneFile(`\n\n`) // console.dir(r, { depth: null }) assert.deepStrictEqual(r, []) }) it('parses a $TTL line', async () => { - const r = zv.parseZoneFile(`$TTL 86400\n`) + const r = await zv.parseZoneFile(`$TTL 86400\n`) // console.dir(r, { depth: null }) - assert.deepStrictEqual(r[0], { ttl: 86400 }) + assert.deepStrictEqual(r[0], { $TTL: 86400 }) }) it('parses a $TTL line with a comment', async () => { - const r = zv.parseZoneFile(`$TTL 86400; yikers\n`) + const r = await zv.parseZoneFile(`$TTL 86400; yikers\n`) // console.dir(r, { depth: null }) - assert.deepStrictEqual(r[0], { ttl: 86400 }) + assert.deepStrictEqual(r[0], { $TTL: 86400 }) }) it(`parses a SOA`, async () => { - const r = zv.parseZoneFile(`example.com. 86400 IN SOA ns1.example.com. hostmaster.example.com. ( + const r = await zv.parseZoneFile(`example.com. 86400 IN SOA ns1.example.com. hostmaster.example.com. ( 2021102100 ; serial 16384 ; refresh 2048 ; retry @@ -52,11 +53,18 @@ describe('parseZoneFile', function () { retry : 2048, expire : 604800, minimum: 2560, + comment: { + expire : ' ; expiry', + minimum: ' ; minimum', + refresh: ' ; refresh', + retry : ' ; retry', + serial : ' ; serial', + }, }) }) it('parses a NS line', async () => { - const r = zv.parseZoneFile(`cadillac.net. 14400 IN NS ns1.cadillac.net.\n`) + const r = await zv.parseZoneFile(`cadillac.net. 14400 IN NS ns1.cadillac.net.\n`) // console.dir(r, { depth: null }) assert.deepStrictEqual(r[0], { name : 'cadillac.net.', @@ -68,7 +76,7 @@ describe('parseZoneFile', function () { }) it('parses an A line', async () => { - const r = zv.parseZoneFile(`cadillac.net. 86400 IN A 66.128.51.173\n`) + const r = await zv.parseZoneFile(`cadillac.net. 86400 IN A 66.128.51.173\n`) // console.dir(r, { depth: null }) assert.deepStrictEqual(r[0], { name : 'cadillac.net.', @@ -79,14 +87,39 @@ describe('parseZoneFile', function () { }) }) + it('parses a CNAME line', async () => { + const r = await zv.parseZoneFile(`www 28800 IN CNAME vhost0.theartfarm.com.\n`) + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r[0], { + name : 'www', + ttl : 28800, + class: 'IN', + type : 'CNAME', + cname: 'vhost0.theartfarm.com.', + }) + }) + + it('parses a DNAME line', async () => { + const r = await zv.parseZoneFile(`_tcp 86400 IN DNAME _tcp.theartfarm.com.\n`) + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r[0], { + name : '_tcp', + ttl : 86400, + class : 'IN', + type : 'DNAME', + target: '_tcp.theartfarm.com.', + }) + }) + it('parses the cadillac.net zone file', async () => { const file = './test/fixtures/zones/cadillac.net' fs.readFile(file, (err, buf) => { if (err) throw err - const r = zv.parseZoneFile(buf.toString()) - // console.dir(r, { depth: null }) - assert.equal(r.length, 41) + zv.parseZoneFile(buf.toString()).then(r => { + // console.dir(r, { depth: null }) + assert.equal(r.length, 41) + }) }) }) @@ -95,9 +128,88 @@ describe('parseZoneFile', function () { fs.readFile(file, (err, buf) => { if (err) throw err - const r = zv.parseZoneFile(buf.toString()) - // console.dir(r, { depth: null }) - assert.equal(r.length, 11) + zv.parseZoneFile(buf.toString()).then(zv.expandShortcuts).then(r => { + // console.dir(r, { depth: null }) + assert.equal(r.length, 11) + }) }) }) }) + +describe('expandShortcuts', function () { + const testCase = [ + { $TTL: 3600 }, + { $ORIGIN: 'test.example.com.' }, + { name: '@', type: 'A', address: '1.2.3.4' }, + ] + + it('expands @ name to $ORIGIN', async () => { + const input = JSON.parse(JSON.stringify(testCase)) + input[2].name = '@' + const out = await zv.expandShortcuts(input) + assert.deepEqual(out, [ new RR.A({ + address: '1.2.3.4', + class : 'IN', + name : 'test.example.com.', + ttl : 3600, + type : 'A', + }) ]) + }) + + it('expands empty name to $ORIGIN', async () => { + const input = JSON.parse(JSON.stringify(testCase)) + input[2].name = '' + + const out = await zv.expandShortcuts(input) + assert.deepEqual(out, [ new RR.A({ + address: '1.2.3.4', + class : 'IN', + name : 'test.example.com.', + ttl : 3600, + type : 'A', + }) ]) + }) + + it('expands empty name to previous', async () => { + const input = JSON.parse(JSON.stringify(testCase)) + input[2] = { + address: '1.2.3.4', + class : 'IN', + name : 'previous.example.com.', + ttl : 3600, + type : 'A', + } + input[3] = { + address: '1.2.3.4', + class : 'IN', + name : '', + ttl : 3600, + type : 'A', + } + + const out = await zv.expandShortcuts(input) + assert.deepEqual(out[1], new RR.A({ + address: '1.2.3.4', + class : 'IN', + name : 'previous.example.com.', + ttl : 3600, + type : 'A', + })) + }) + + it('expands TTL to zone minimum', async () => { + const input = JSON.parse(JSON.stringify(testCase)) + input[0] = input[1] + const r = await zv.parseZoneFile(`@ 55 IN SOA ns1.cadillac.net. hostmaster.cadillac.net. (2021102100 16384 2048 604800 2560)\n`) + input[1] = r[0] + + const out = await zv.expandShortcuts(input) + assert.deepEqual(out[1], new RR.A({ + address: '1.2.3.4', + class : 'IN', + name : 'test.example.com.', + ttl : 2560, + type : 'A', + })) + }) +}) \ No newline at end of file