diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index be19ef4..0986a1e 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -21,7 +21,7 @@ jobs: with: fetch-depth: 1 - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v2 name: Use Node.js ${{ matrix.node-version }} with: node-version: ${{ matrix.node-version }} diff --git a/.gitignore b/.gitignore index 6704566..d72d10d 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port +grammar.js +package-lock.json diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..cd07f0e --- /dev/null +++ b/.npmignore @@ -0,0 +1,14 @@ +.github +.DS_Store +.editorconfig +.gitignore +.gitmodules +.lgtm.yml +appveyor.yml +codecov.yml +.release +.travis.yml +.eslintrc.yaml +.eslintrc.json +package-lock.json +grammar.js diff --git a/index.js b/index.js index e69de29..57488b7 100644 --- a/index.js +++ b/index.js @@ -0,0 +1,26 @@ + +const nearley = require('nearley') +const grammar = require('./grammar.js') +grammar.start = 'main' + +exports.parseZoneFile = str => { + + const parser = new nearley.Parser(nearley.Grammar.fromCompiled(grammar)) + parser.feed(str) + parser.feed(`\n`) // for no EOL after last record + + if (parser.length > 1) { + console.error(`ERROR: ambigious parser rule`) + } + + // flatten the parser generated array + const flat = [] + for (const e of parser.results[0][0]) { + + // discard blank lines + if (Array.isArray(e[0][0]) && e[0][0][0] === null) continue + + flat.push(e[0][0]) + } + return flat +} diff --git a/package.json b/package.json index 20560be..6ba9c86 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "dns-zone-validator", - "version": "0.0.2", + "version": "0.1.0", "description": "DNS Zone Validator", "main": "index.js", "scripts": { "cover": "NODE_ENV=cov npx nyc --reporter=lcovonly npm run test", - "lint": "npx eslint *.js test/*.js", - "lintfix": "npx eslint --fix *.js test/*.js", + "grammar": "npx -p nearley nearleyc src/bind-grammar.ne -o grammar.js", + "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" }, "repository": { @@ -30,5 +32,6 @@ "mocha": "^9.1.3" }, "dependencies": { + "nearley": "^2.20.1" } } diff --git a/src/bind-grammar.ne b/src/bind-grammar.ne new file mode 100644 index 0000000..f7a32fc --- /dev/null +++ b/src/bind-grammar.ne @@ -0,0 +1,185 @@ + +#@builtin "number.ne" +@builtin "string.ne" +@builtin "whitespace.ne" + + +main -> (statement eol):+ + +statement -> blank | ttl | origin | soa | ns | mx | a | txt | aaaa | cname | dname + +eol -> "\n" | "\r" + +blank -> _ + +comment -> ";" [^\n]:* + +ttl -> "$TTL" __ uint _ (comment):? _ + {% (d) => ttlAsObject(d) %} + +origin -> "$ORIGIN" __ hostname (comment):? _ + {% (d) => originAsObject(d) %} + +soa -> hostname ( __ uint ):? ( __ class ):? __ "SOA" + __ hostname + __ hostname + __ "(" + __ uint (_ comment):? + __ uint (_ comment):? + __ uint (_ comment):? + __ uint (_ comment):? + __ uint (_ comment):? + _ ")" _ (comment):? + {% (d) => toResourceRecord(d) %} + +ns -> hostname (__ uint):? (__ class):? __ "NS" + __ hostname _ (comment):? _ + {% (d) => toResourceRecord(d) %} + +mx -> hostname (__ uint):? (__ class):? __ "MX" + __ uint __ hostname _ (comment):? + {% (d) => toResourceRecord(d) %} + +a -> hostname (__ uint):? (__ class):? __ "A" + __ ip4 _ (comment):? _ + {% (d) => toResourceRecord(d) %} + +txt -> hostname (__ uint):? (__ class):? __ "TXT" + __ (dqstring _):+ (comment):? _ + {% (d) => toResourceRecord(d) %} + +aaaa -> hostname (__ uint):? (__ class):? __ "AAAA" + __ ip6 _ (comment):? _ + {% (d) => toResourceRecord(d) %} + +cname -> hostname (__ uint):? (__ class):? __ "CNAME" + __ hostname _ (comment):? _ + {% (d) => toResourceRecord(d) %} + +dname -> hostname (__ uint):? (__ class):? __ "DNAME" + __ hostname _ (comment):? _ + {% (d) => toResourceRecord(d) %} + +uint -> [0-9]:+ {% (d) => parseInt(d[0].join("")) %} + +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" + +times_3[X] -> $X $X $X +times_5[X] -> $X $X $X $X $X +times_7[X] -> $X $X $X $X $X $X $X + +ip4 -> Snum times_3["." Snum] {% (d) => flat_string(d) %} + +ip6 -> IPv6_full | IPv6_comp | IPv6v4_full | IPv6v4_comp + +Snum -> DIGIT | + ( [1-9] DIGIT ) | + ( "1" DIGIT DIGIT ) | + ( "2" [0-4] DIGIT ) | + ( "2" "5" [0-5] ) + +DIGIT -> [0-9] {% id %} +HEXDIG -> [0-9A-Fa-f] {% id %} + +IPv6_hex -> HEXDIG | + ( HEXDIG HEXDIG ) | + ( HEXDIG HEXDIG HEXDIG ) | + ( HEXDIG HEXDIG HEXDIG HEXDIG ) + +IPv6_full -> IPv6_hex times_7[":" IPv6_hex] + {% (d) => flat_string(d) %} + +IPv6_comp -> (IPv6_hex times_5[":" IPv6_hex]):? "::" + (IPv6_hex times_5[":" IPv6_hex]):? + {% (d) => flat_string(d) %} + +IPv6v4_full -> IPv6_hex times_5[":" IPv6_hex] ":" ip4 + {% (d) => flat_string(d) %} + +IPv6v4_comp -> (IPv6_hex times_3[":" IPv6_hex]):? "::" + (IPv6_hex times_3[":" IPv6_hex] ":"):? + ip4 + {% (d) => flat_string(d) %} + + +#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 '' +} + +function ttlAsObject (d) { + return { ttl: d[2] } +} + +function originAsObject (d) { + 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 +} + +%} \ No newline at end of file diff --git a/test/fixtures/zones/cadillac.net b/test/fixtures/zones/cadillac.net new file mode 100644 index 0000000..6ea967c --- /dev/null +++ b/test/fixtures/zones/cadillac.net @@ -0,0 +1,56 @@ + +$TTL 86400; +$ORIGIN cadillac.net. +cadillac.net. 86400 IN SOA ns1.cadillac.net. hostmaster.cadillac.net. ( + 2021102100 ; serial + 16384 ; refresh + 2048 ; retry + 604800 ; expiry + 2560 ; minimum + ) + +cadillac.net. 14400 IN NS ns1.cadillac.net. +cadillac.net. 14400 IN NS ns2.cadillac.net. +cadillac.net. 14400 IN NS ns3.cadillac.net. + +cadillac.net. 86400 IN MX 0 mail.theartfarm.com. +cadillac.net. 86400 IN A 66.128.51.173 +cadillac.net. 86400 IN TXT "v=spf1 mx a include:mx.theartfarm.com -all" + +ns1 86400 IN MX 10 ns1.cadillac.net. +ns1 86400 IN A 138.210.133.61 + +ns2 86400 IN A 192.48.85.146 +ns2 86400 IN AAAA 2605:7900:0020:000a:0000:0000:0000:0004 +ns2 86400 IN A 204.11.99.4 +ns2 86400 IN A 173.45.131.4 +ns2 86400 IN MX 10 ns2.cadillac.net. + +ns3 86400 IN MX 10 ns3.cadillac.net. +ns3 86400 IN AAAA 2605:ae00:0329:0000:0000:0000:0000:000f +ns3 86400 IN A 66.128.51.174 +ns3 86400 IN AAAA 2001:41d0:0302:2100:0000:0000:0000:00a7 +ns3 86400 IN A 217.182.64.150 + +ns4 3600 IN A 208.78.70.12 +ns4 3600 IN AAAA 2001:0500:0090:0001:0000:0000:0000:0012 +ns4 3600 IN A 204.13.250.12 +ns4 3600 IN A 208.78.71.12 +ns4 3600 IN A 204.13.251.12 +ns4 3600 IN AAAA 2001:0500:0094:0001:0000:0000:0000:0012 + +localhost 86400 IN A 127.0.0.1 +localhost 86400 IN AAAA 0000:0000:0000:0000:0000:0000:0000:0001 + +www 28800 IN CNAME vhost0.theartfarm.com. +matt 86400 IN CNAME matt.simerson.net. +dns 3600 IN A 66.128.51.171 +jaycees 86400 IN CNAME www.cadillacjaycees.org. +kyna 86400 IN CNAME vhost0.theartfarm.com. +_tcp 86400 IN DNAME _tcp.theartfarm.com. +_dmarc 86400 IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc-feedback@theartfarm.com; ruf=mailto:dmarc-feedback@theartfarm.com; pct=100" +apr2013._domainkey 86400 IN TXT "v=DKIM1;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5BgmgaBHxIQSHCzFlkJ8/dCYFgppfpGxQB2mbB/nlspbZZPQPX7JrS8fglt6lVOQ/A82ErayWiABQd6GziiHbe+mA5glQSxG2o2LUtDa1AU269W1sZrgVEFkIq5sZ+T+s3KbcSjca21YOZt8NWxw5UvP1xTRHHO77JbcwUEB4rBAiZOs8eU9kuMLAuh8AQw0w17JW0+tN" "SNSphz0dY5S/5upHSdRqyOVrCJNE/Zuyzo1Ck+T1NIPt4ttd1VPkAMnjqXXjBQWP4BRObVEdmRqCxy4CRfbbiPJiNcut+iV2YezJqsVxBXwPFfsMwVb68aAHKKpdwrBfmNfv/yLdXY6RwIDAQAB" +_domainkey 86400 IN TXT "o=-; t=y; r=postmaster@cadillac.net" +france.ns3 86400 IN A 217.182.64.150 +dallas.ns3 86400 IN A 66.128.51.174 +xn--ber-goa 86400 IN A 127.0.0.1 diff --git a/test/fixtures/zones/isi.edu b/test/fixtures/zones/isi.edu new file mode 100644 index 0000000..f851880 --- /dev/null +++ b/test/fixtures/zones/isi.edu @@ -0,0 +1,20 @@ +@ IN SOA VENERA Action.domains ( + 20 ; SERIAL + 7200 ; REFRESH + 600 ; RETRY + 3600000; EXPIRE + 60) ; MINIMUM + + NS A.ISI.EDU. + NS VENERA + NS VAXA + MX 10 VENERA + MX 20 VAXA + +A A 26.3.0.103 + +VENERA A 10.1.0.52 + A 128.9.0.32 + +VAXA A 10.2.0.27 + A 128.9.0.33 \ No newline at end of file diff --git a/test/index.js b/test/index.js index e69de29..ab80dfb 100644 --- a/test/index.js +++ b/test/index.js @@ -0,0 +1,103 @@ + +const assert = require('assert') +const fs = require('fs') + +const zv = require('../index') + +describe('parseZoneFile', function () { + + it('parses a blank line', async () => { + const r = zv.parseZoneFile(`\n`) + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r, []) + }) + + it('parses two blank lines', async () => { + const r = 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`) + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r[0], { ttl: 86400 }) + }) + + it('parses a $TTL line with a comment', async () => { + const r = zv.parseZoneFile(`$TTL 86400; yikers\n`) + // console.dir(r, { depth: null }) + 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. ( + 2021102100 ; serial + 16384 ; refresh + 2048 ; retry + 604800 ; expiry + 2560 ; minimum + )\n`) + + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r[0], { + name : 'example.com.', + ttl : 86400, + class : 'IN', + type : 'SOA', + mname : 'ns1.example.com.', + rname : 'hostmaster.example.com.', + serial : 2021102100, + refresh: 16384, + retry : 2048, + expire : 604800, + minimum: 2560, + }) + }) + + it('parses a NS line', async () => { + const r = zv.parseZoneFile(`cadillac.net. 14400 IN NS ns1.cadillac.net.\n`) + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r[0], { + name : 'cadillac.net.', + ttl : 14400, + class: 'IN', + type : 'NS', + dname: 'ns1.cadillac.net.', + }) + }) + + it('parses an A line', async () => { + const r = zv.parseZoneFile(`cadillac.net. 86400 IN A 66.128.51.173\n`) + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r[0], { + name : 'cadillac.net.', + ttl : 86400, + class : 'IN', + type : 'A', + address: '66.128.51.173', + }) + }) + + 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) + }) + }) + + it('parses the isi.edu zone file', async () => { + const file = './test/fixtures/zones/isi.edu' + 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) + }) + }) +})