diff --git a/CHANGES.md b/CHANGES.md index cfbe85b..8601936 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,24 @@ #### 1.N.N - YYYY-MM-DD +#### 0.3.0 - 2022-03-24 + +- import + - added CLI options + - added tinydns ingest support + - encapsulated output logic +- pass zone_opts to RR exporter +- export: add JSON +- index: import fullyQualify from dns-rr +- grammar + - improved ip6 compressed parsing + - add PTR support in bind zone files +- test + - added zonefile example.com, localhost + - added relative CNAME test +- README: expand with examples + + #### 0.2.0 - 2022-03-22 - add expandShortcuts diff --git a/README.md b/README.md index 26c0c56..04fd20d 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,175 @@ 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. +## BIN/IMPORT + +#### show help + +```` +➜ dns-zone-validator ✗ ./bin/import.js -h + + +-+-+-+ +-+-+-+-+ + |D|N|S| |Z|O|N|E| + +-+-+-+ +-+-+-+-+ + +I/O + + -i, --import source of DNS zone data (default: stdin) + -e, --export zone data export format (default: js) + +Zone Settings + + -o, --origin string zone $ORIGIN + -t, --ttl number zone default TTL + -c, --class string zone class (default: IN) + +Output Options + + --hide-origin remove origin from RR domain names (default: false) + --hide-class hide class (default: false) + --hide-ttl hide TTLs (default: false) + +Misc + + -v, --verbose Show status messages during processing + -h, --help Display this usage guide + +Examples + + 1. BIND file to tinydns ./bin/import -i ./isi.edu -e tinydns + 2. BIND file to JS objects ./bin/import -i ./isi.edu + 3. tinydns file to BIND ./bin/import -i ./data -e bind + + Project home: https://github.com/msimerson/dns-zone-validator +```` + + +#### import to JS + +```` +➜ cat isi.edu | ./bin/import.js --origin=isi.edu +[ + SOA(12) [Map] { + 'name' => 'isi.edu.', + 'ttl' => 60, + 'class' => 'IN', + 'type' => 'SOA', + 'mname' => 'venera.isi.edu.', + 'rname' => 'action.domains.isi.edu.', + 'serial' => 20, + 'refresh' => 7200, + 'retry' => 600, + 'expire' => 3600000, + 'minimum' => 60, + 'comment' => { + serial: ' ; SERIAL', + refresh: ' ; REFRESH', + retry: ' ; RETRY', + expire: '; EXPIRE', + minimum: '' + } + }, + NS(5) [Map] { + 'name' => 'isi.edu.', + 'ttl' => 60, + 'class' => 'IN', + 'type' => 'NS', + 'dname' => 'a.isi.edu.' + }, +...... + A(5) [Map] { + 'name' => 'vaxa.isi.edu.', + 'ttl' => 60, + 'class' => 'IN', + 'type' => 'A', + 'address' => '128.9.0.33' + } +] +```` + +#### to bind + +```` +➜ ./bin/import.js -i isi.edu -e bind +$TTL 60 +$ORIGIN isi.edu. +isi.edu. IN SOA venera.isi.edu. action.domains.isi.edu. ( + 20 ; SERIAL + 7200 ; REFRESH + 600 ; RETRY + 3600000; EXPIRE + 60 + ) + +isi.edu. 60 IN NS A.ISI.EDU. +isi.edu. 60 IN NS venera.isi.edu. +isi.edu. 60 IN NS vaxa.isi.edu. +isi.edu. 60 IN MX 10 venera.isi.edu. +isi.edu. 60 IN MX 20 vaxa.isi.edu. +a 60 IN A 26.3.0.103 +venera 60 IN A 10.1.0.52 +venera 60 IN A 128.9.0.32 +vaxa 60 IN A 10.2.0.27 +vaxa 60 IN A 128.9.0.33 +```` + +#### to bind (relative) + +```` +➜ ./bin/import.js -i isi.edu -e bind --ttl=60 --hide-ttl --hide-class --hide-origin +$TTL 60 +$ORIGIN isi.edu. +@ IN SOA venera action.domains ( + 20 ; SERIAL + 7200 ; REFRESH + 600 ; RETRY + 3600000; EXPIRE + 60 + ) + +@ NS a +@ NS venera +@ NS vaxa +@ MX 10 venera +@ MX 20 vaxa +a A 26.3.0.103 +venera A 10.1.0.52 +venera A 128.9.0.32 +vaxa A 10.2.0.27 +vaxa A 128.9.0.33 +```` + + +#### to tinydns + +```` +➜ ./bin/import.js -i isi.edu -e tinydns +Zisi.edu:venera.isi.edu:action.domains.isi.edu:20:7200:600:3600000:60:60:: +&isi.edu::A.ISI.EDU:60:: +&isi.edu::venera.isi.edu:60:: +&isi.edu::vaxa.isi.edu:60:: +@isi.edu::venera.isi.edu:10:60:: +@isi.edu::vaxa.isi.edu:20:60:: ++a.isi.edu:26.3.0.103:60:: ++venera.isi.edu:10.1.0.52:60:: ++venera.isi.edu:128.9.0.32:60:: ++vaxa.isi.edu:10.2.0.27:60:: ++vaxa.isi.edu:128.9.0.33:60:: +```` + ## TODO - [ ] write a named.conf file parser - [x] write a bind zone file parser +- [ ] write a tinydns data 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 + - expand hostnames to FQDNs - [x] ALL: name field - - [x] MX: exchange, CNAME: cname, SOA: mname, rname, NS: dname \ No newline at end of file + - [x] MX: exchange + - [x] CNAME: cname, + - [x] SOA: mname, rname, + - [x] NS,PTR: dname +- [ ] validate zone rules diff --git a/bin/import.js b/bin/import.js index b8ffb87..0122fd5 100755 --- a/bin/import.js +++ b/bin/import.js @@ -4,61 +4,236 @@ const fs = require('fs') const path = require('path') const os = require('os') +const chalk = require('chalk') +const cmdLineArgs = require('command-line-args') +const cmdLineUsage = require('command-line-usage') + const dz = require('../index') +const RR = require('dns-resource-record') +const rr = new RR.A(null) + +// CLI argument processing +const opts = cmdLineArgs(usageOptions())._all +if (opts.verbose) console.error(opts) +if (opts.help) usage() + +const zone_opts = { + origin: rr.fullyQualify(opts.origin) || '', + ttl : opts.ttl || 0, + class : opts.class || 'IN', + hide : { + class : opts['hide-class'], + ttl : opts['hide-ttl'], + origin: opts['hide-origin'], + }, +} +if (opts.verbose) console.error(zone_opts) + +ingestZoneData() + .then(r => { + switch (r.type) { + case 'tinydns': + return dz.parseTinydnsData(r.data) + default: + return dz.parseZoneFile(r.data).then(dz.expandShortcuts) + } + }) + .then(output) + .catch(e => { + console.error(e.message) + }) -const filePath = process.argv[2] -if (!filePath) usage() function usage () { - console.log(`\n ${process.argv[1]} file\n`) + console.error(cmdLineUsage(usageSections())) 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) +function usageOptions () { + return [ + { + name : 'import', + alias : 'i', + defaultValue: 'stdin', + type : String, + typeLabel : '', + description : 'source of DNS zone data (default: stdin)', + group : 'io', + }, + { + name : 'export', + alias : 'e', + defaultValue: 'js', + type : String, + typeLabel : '', + description : 'zone data export format (default: js)', + group : 'io', + }, + { + name : 'origin', + alias : 'o', + type : String, + description: 'zone $ORIGIN', + group : 'main', + }, + { + name : 'ttl', + alias : 't', + type : Number, + description: 'zone default TTL', + group : 'main', + }, + { + name : 'class', + alias : 'c', + defaultValue: 'IN', + type : String, + description : 'zone class (default: IN)', + group : 'main', + }, + { + name : 'hide-origin', + defaultValue: false, + type : Boolean, + // typeLabel : '', + description : 'remove origin from RR domain names (default: false)', + group : 'out', + }, + { + name : 'hide-class', + defaultValue: false, + type : Boolean, + // typeLabel : '', + description : 'hide class (default: false)', + group : 'out', + }, + { + name : 'hide-ttl', + defaultValue: false, + type : Boolean, + // typeLabel : '', + description : 'hide TTLs (default: false)', + group : 'out', + }, + { + name : 'verbose', + alias : 'v', + description: 'Show status messages during processing', + type : Boolean, + }, + { + name : 'help', + description: 'Display this usage guide', + alias : 'h', + type : Boolean, + }, + ] +} + +function usageSections () { + return [ + { + content: chalk.blue(` +-+-+-+ +-+-+-+-+\n |D|N|S| |Z|O|N|E|\n +-+-+-+ +-+-+-+-+`), + raw : true, + }, + { + header : 'I/O', + optionList: usageOptions(), + group : 'io', + }, + { + header : 'Zone Settings', + optionList: usageOptions(), + group : 'main', + }, + { + header : 'Output Options', + optionList: usageOptions(), + group : 'out', + }, + { + header : 'Misc', + optionList: usageOptions(), + group : '_none', + }, + { + header : 'Examples', + content: [ + { + desc : '1. BIND file to tinydns', + example: './bin/import -i ./isi.edu -e tinydns', + }, + { + desc : '2. BIND file to JS objects', + example: './bin/import -i ./isi.edu', + }, + { + desc : '3. tinydns file to BIND', + example: './bin/import -i ./data -e bind', + }, + ], + }, + { + content: 'Project home: {underline https://github.com/msimerson/dns-zone-validator}', + }, + ] +} + +function ingestZoneData () { + return new Promise((resolve, reject) => { + + const res = { type: 'bind' } + if (!opts.import) usage() + + let filePath = opts.import + + if (filePath === 'stdin') { + filePath = process.stdin.fd + } + else if (path.basename(filePath) === 'data'){ + res.type = 'tinydns' + } + else { + if (!opts.origin) zone_opts.origin = rr.fullyQualify(path.basename(filePath)) + } + + if (opts.verbose) console.error(`reading file ${filePath}`) + + fs.readFile(filePath, (err, buf) => { + if (err) return reject(err) + + res.data = buf.toString() + + if (res.type === 'bind' && !/^\$ORIGIN/m.test(res.data)) { + if (opts.verbose) console.error(`inserting $ORIGIN ${zone_opts.origin}`) + res.data = `$ORIGIN ${zone_opts.origin}${os.EOL}${res.data}` } - }) - .catch(e => { - console.error(e.message) - }) -}) -function fileAsString (buf, base) { - let str = buf.toString() + resolve(res) + }) + }) +} - if (!/^\$ORIGIN/m.test(str)) { - console.log(`inserting $ORIGIN ${base}`) - str = `$ORIGIN ${base}.${os.EOL}${str}` +function output (zoneArray) { + // console.error(zoneArray) + switch (opts.export.toLowerCase()) { + case 'json': + toJSON(zoneArray) + break + case 'bind': + toBind(zoneArray, zone_opts.origin) + break + case 'tinydns': + toTinydns(zoneArray) + break + default: + console.log(zoneArray) } - // 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) + process.stdout.write(rr.toBind(zone_opts)) } } @@ -66,4 +241,12 @@ function toTinydns (zoneArray) { for (const rr of zoneArray) { process.stdout.write(rr.toTinydns()) } +} + +function toJSON (zoneArray) { + for (const rr of zoneArray) { + // console.error(rr) + if (rr.get('comment')) rr.delete('comment') + process.stdout.write(JSON.stringify(Object.fromEntries(rr))) + } } \ No newline at end of file diff --git a/index.js b/index.js index a278d56..8e69708 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,13 @@ -const nearley = require('nearley') const RR = require('dns-resource-record') - -const grammar = nearley.Grammar.fromCompiled(require('./grammar.js')) -grammar.start = 'main' +const rr = new RR.A(null) exports.parseZoneFile = async str => { + const nearley = require('nearley') + const grammar = nearley.Grammar.fromCompiled(require('./grammar.js')) + grammar.start = 'main' + const parser = new nearley.Parser(grammar) parser.feed(str) parser.feed(`\n`) // in case no EOL after last record @@ -47,11 +48,11 @@ exports.expandShortcuts = async zoneArray => { // 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) + implicitOrigin = origin = rr.fullyQualify(entry.implicitOrigin) continue } if (entry.$ORIGIN) { // declared $ORIGIN within zone file - origin = fullyQualify(entry.$ORIGIN, implicitOrigin) + origin = rr.fullyQualify(entry.$ORIGIN, implicitOrigin) continue } if (!origin) throw new Error(`zone origin ambiguous, cowardly bailing out`) @@ -68,15 +69,15 @@ exports.expandShortcuts = async zoneArray => { if (entry.name === '' && lastName) entry.name = lastName if (entry.name) { - entry.name = fullyQualify(entry.name, origin) + entry.name = rr.fullyQualify(entry.name, origin) } else { - entry.name = `${origin}`.toLowerCase() + entry.name = origin } if (entry.name !== lastName) lastName = entry.name - expandRdata(entry, origin, ttl) + expandBindRdata(entry, origin, ttl) try { expanded.push(new RR[entry.type](entry)) @@ -91,23 +92,251 @@ exports.expandShortcuts = async zoneArray => { return expanded } -function fullyQualify (hostname, origin) { - if (hostname.endsWith('.')) return hostname - return `${hostname}.${origin}`.toLowerCase() -} - -function expandRdata (entry, origin, ttl) { +function expandBindRdata (entry, origin, ttl) { switch (entry.type) { case 'SOA': for (const f of [ 'mname', 'rname' ]) { - entry[f] = fullyQualify(entry[f], origin) + entry[f] = rr.fullyQualify(entry[f], origin) } break case 'MX': - entry.exchange = fullyQualify(entry.exchange, origin) + entry.exchange = rr.fullyQualify(entry.exchange, origin) break case 'NS': - entry.dname = fullyQualify(entry.dname, origin) + entry.dname = rr.fullyQualify(entry.dname, origin) + break + case 'CNAME': + entry.cname = rr.fullyQualify(entry.cname, origin) break } -} \ No newline at end of file +} + +exports.parseTinydnsData = async str => { + // https://cr.yp.to/djbdns/tinydns-data.html + const rrs = [] + + for (const line of str.split('\n')) { + if (line === '') continue // "Blank lines are ignored" + if (/^#/.test(line)) continue // "Comment line. The line is ignored." + switch (line[0]) { // first char of line + case '%': // location + break + case '-': // ignored + break + case '.': // NS, A, SOA + rrs.push(...parseTinyDot(line)) + break + case '&': // NS, A + rrs.push(...parseTinyAmpersand(line)) + break + case '=': // A, PTR + rrs.push(...parseTinyEquals(line)) + break + case '+': // A + rrs.push(new RR.A({ tinyline: line })) + break + case '@': // MX, A + rrs.push(...parseTinyAt(line)) + break + case '\'': // TXT + rrs.push(new RR.TXT({ tinyline: line })) + break + case '^': // PTR + rrs.push(new RR.PTR({ tinyline: line })) + break + case 'C': // CNAME + rrs.push(new RR.CNAME({ tinyline: line })) + break + case 'Z': // SOA + rrs.push(new RR.SOA({ tinyline: line })) + break + case ':': // generic + rrs.push(parseTinyGeneric(line)) + break + case '3': + rrs.push(new RR.AAAA({ tinyline: line })) + break + case '6': + rrs.push(...parseTinySix(line)) + break + case 'S': // SRV + rrs.push(new RR.SRV({ tinyline: line })) + break + default: + throw new Error(`garbage found in tinydns data: ${line}`) + } + // console.log(line) + } + + return rrs +} + +function parseTinyDot (str) { + /* + * .fqdn:ip:x:ttl:timestamp:lo + * an NS (``name server'') record showing x.ns.fqdn as a name server for fqdn; + * an A (``address'') record showing ip as the IP address of x.ns.fqdn; and + * an SOA (``start of authority'') record for fqdn listing x.ns.fqdn as the primary name server and hostmaster@fqdn as the contact address. + */ + const [ fqdn, ip, mname, ttl, ts, loc ] = str.substring(1).split(':') + const rrs = [] + + rrs.push(new RR.NS({ + type : 'NS', + name : rr.fullyQualify(fqdn), + dname : rr.fullyQualify(/\./.test(mname) ? mname : `${mname}.ns.${fqdn}`), + ttl : parseInt(ttl, 10), + timestamp: ts, + location : loc !== '' && loc !== '\n' ? loc : '', + })) + + if (ip) { + rrs.push(new RR.A({ + name : rr.fullyQualify(/\./.test(mname) ? mname : `${mname}.ns.${fqdn}`), + type : 'A', + address : ip, + ttl : parseInt(ttl, 10), + timestamp: ts, + location : loc !== '' && loc !== '\n' ? loc : '', + })) + } + + rrs.push(new RR.SOA({ + type : 'SOA', + name : rr.fullyQualify(fqdn), + mname : rr.fullyQualify(/\./.test(mname) ? mname : `${mname}.ns.${fqdn}`), + rname : rr.fullyQualify(`hostmaster.{fqdn}`), + serial : 1647927758, // TODO, format is epoch seconds + refresh : 16384, + retry : 2048, + expire : 1048576, + minimum : 2560, + ttl : parseInt(ttl, 10), + timestamp: parseInt(ts) || '', + location : loc !== '' && loc !== '\n' ? loc : '', + })) + return rrs +} + +function parseTinyAmpersand (str) { + // &fqdn:ip:x:ttl:timestamp:lo + + const [ fqdn, ip, dname, ttl, ts, loc ] = str.substring(1).split(':') + const rrs = [] + + rrs.push(new RR.NS({ + type : 'NS', + name : rr.fullyQualify(fqdn), + dname : rr.fullyQualify(/\./.test(dname) ? dname : `${dname}.ns.${fqdn}`), + ttl : parseInt(ttl, 10), + timestamp: ts, + location : loc !== '' && loc !== '\n' ? loc : '', + })) + + if (ip) { + rrs.push(new RR.A({ + name : rr.fullyQualify(/\./.test(dname) ? dname : `${dname}.ns.${fqdn}`), + type : 'A', + address : ip, + ttl : parseInt(ttl, 10), + timestamp: ts, + location : loc !== '' && loc !== '\n' ? loc : '', + })) + } + + return rrs +} + +function parseTinyEquals (str) { + // =fqdn:ip:ttl:timestamp:lo + const rrs = [ new RR.A({ tinyline: str }) ] + + const [ fqdn, ip, ttl, ts, loc ] = str.substring(1).split(':') + rrs.push(new RR.PTR({ + type : 'PTR', + name : `${ip.split('.').reverse().join('.')}.in-addr.arpa`, + dname : rr.fullyQualify(fqdn), + ttl : parseInt(ttl, 10), + timestamp: ts, + location : loc !== '' && loc !== '\n' ? loc : '', + })) + + return rrs +} + +function parseTinyAt (str) { + // MX, A @fqdn:ip:x:dist:ttl:timestamp:lo + const rrs = [ new RR.MX({ tinyline: str }) ] + + // eslint-disable-next-line no-unused-vars + const [ fqdn, ip, x, preference, ttl, ts, loc ] = str.substring(1).split(':') + if (ip) { + rrs.push(new RR.A({ + name : rr.fullyQualify(/\./.test(x) ? x : `${x}.mx.${fqdn}`), + type : 'A', + address : ip, + ttl : parseInt(ttl, 10), + timestamp: ts, + location : loc !== '' && loc !== '\n' ? loc : '', + })) + } + + return rrs +} + +function parseTinySix (str) { + // AAAA,PTR => 6 fqdn:ip:x:ttl:timestamp:lo + const rrs = [ new RR.AAAA({ tinyline: str }) ] + + const [ fqdn, rdata, , ttl, ts, loc ] = str.substring(1).split(':') + + rrs.push(new RR.PTR({ + type : 'PTR', + name : `${rdata.split('').reverse().join('.')}.ip6.arpa.`, + dname : rr.fullyQualify(fqdn), + ttl : parseInt(ttl, 10), + timestamp: ts, + location : loc !== '' && loc !== '\n' ? loc : '', + })) + return rrs +} + +function parseTinyGeneric (str) { + // generic, :fqdn:n:rdata:ttl:timestamp:lo + + const [ , n, , , , ] = str.substring(1).split(':') + + switch (parseInt(n, 10)) { + case 13: + return new RR.HINFO({ tinyline: str }) + case 28: + return new RR.AAAA({ tinyline: str }) + case 29: + return new RR.LOC({ tinyline: str }) + case 33: + return new RR.SRV({ tinyline: str }) + case 35: + return new RR.NAPTR({ tinyline: str }) + case 39: + return new RR.DNAME({ tinyline: str }) + case 43: + return new RR.DS({ tinyline: str }) + case 44: + return new RR.SSHFP({ tinyline: str }) + case 48: + return new RR.DNSKEY({ tinyline: str }) + case 52: + return new RR.TLSA({ tinyline: str }) + case 53: + return new RR.SMIMEA({ tinyline: str }) + case 99: + return new RR.SPF({ tinyline: str }) + case 256: + return new RR.URI({ tinyline: str }) + case 257: + return new RR.CAA({ tinyline: str }) + default: + console.log(str) + throw new Error(`unsupported tinydns generic record (${n})`) + } +} diff --git a/package.json b/package.json index 5dd8607..585ef5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dns-zone-validator", - "version": "0.2.0", + "version": "0.3.0", "description": "DNS Zone Validator", "main": "index.js", "scripts": { @@ -33,7 +33,9 @@ "mocha": "^9.2.2" }, "dependencies": { - "dns-resource-record": "^0.9.3", + "command-line-args": "^5.2.1", + "command-line-usage": "^6.1.1", + "dns-resource-record": "^0.9.4", "nearley": "^2.20.1" } } diff --git a/src/bind-grammar.ne b/src/bind-grammar.ne index fb55da5..1c156da 100644 --- a/src/bind-grammar.ne +++ b/src/bind-grammar.ne @@ -5,7 +5,7 @@ main -> (statement eol):+ -statement -> blank | ttl | origin | soa | ns | mx | a | txt | aaaa | cname | dname +statement -> blank | ttl | origin | soa | ns | mx | a | txt | aaaa | cname | dname | ptr eol -> "\n" | "\r" @@ -13,57 +13,48 @@ blank -> _ comment -> ";" [^\n\r]:* -ttl -> "$TTL" __ uint _ (comment):? _ - {% (d) => ttlAsObject(d) %} +ttl -> "$TTL" __ uint _ (comment):? _ {% asTTL %} -origin -> "$ORIGIN" __ hostname (comment):? _ - {% (d) => originAsObject(d) %} +origin -> "$ORIGIN" __ hostname _ (comment):? _ {% asOrigin %} soa -> hostname ( __ uint ):? ( __ class ):? __ "SOA" - __ hostname - __ hostname - __ "(" + __ hostname __ hostname __ "(" _ uint (ws comment):? __ uint (ws comment):? __ uint (ws comment):? __ uint (ws comment):? __ uint (ws comment):? - _ ")" _ (comment):? - {% (d) => toResourceRecord(d) %} + _ ")" _ (comment):? {% asRR %} ns -> hostname (__ uint):? (__ class):? __ "NS" - __ hostname _ (comment):? _ - {% (d) => toResourceRecord(d) %} + __ hostname _ (comment):? _ {% asRR %} mx -> hostname (__ uint):? (__ class):? __ "MX" - __ uint __ hostname _ (comment):? - {% (d) => toResourceRecord(d) %} + __ uint __ hostname _ (comment):? {% asRR %} a -> hostname (__ uint):? (__ class):? __ "A" - __ ip4 _ (comment):? _ - {% (d) => toResourceRecord(d) %} + __ ip4 _ (comment):? _ {% asRR %} txt -> hostname (__ uint):? (__ class):? __ "TXT" - __ (dqstring _):+ (comment):? _ - {% (d) => toResourceRecord(d) %} + __ (dqstring _):+ (comment):? _ {% asRR %} aaaa -> hostname (__ uint):? (__ class):? __ "AAAA" - __ ip6 _ (comment):? _ - {% (d) => toResourceRecord(d) %} + __ ip6 _ (comment):? _ {% asRR %} cname -> hostname (__ uint):? (__ class):? __ "CNAME" - __ hostname _ (comment):? _ - {% (d) => toResourceRecord(d) %} + __ hostname _ (comment):? _ {% asRR %} dname -> hostname (__ uint):? (__ class):? __ "DNAME" - __ hostname _ (comment):? _ - {% (d) => toResourceRecord(d) %} + __ hostname _ (comment):? _ {% asRR %} + +ptr -> hostname (__ uint):? (__ class):? __ "PTR" + __ hostname _ (comment):? _ {% asRR %} 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 %} +ALPHA_NUM_DASH_U -> [0-9A-Za-z\u0080-\uFFFF\.\-_@] {% id %} class -> "IN" | "CS" | "CH" | "HS" | "NONE" | "ANY" @@ -74,42 +65,34 @@ 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) %} - -#ip4 -> ([0-9.]):+ {% (d) => flat_string(d) %} -#ip6 -> (ip6_chars):+ {% (d) => flat_string(d) %} -#ip6_chars -> [0-9A-Fa-f:.] {% id %} +ip4 -> int8 times_3["." int8] {% flatten %} -ip6 -> IPv6_full | IPv6_comp | IPv6v4_full | IPv6v4_comp +ip6 -> ip6_full | ip6_compressed | IPv6v4_full | IPv6v4_comp -Snum -> DIGIT | - ( [1-9] DIGIT ) | - ( "1" DIGIT DIGIT ) | - ( "2" [0-4] DIGIT ) | - ( "2" "5" [0-5] ) +int8 -> DIGIT | + [1-9] DIGIT | + "1" DIGIT DIGIT | + "2" [0-4] DIGIT | + "25" [0-5] DIGIT -> [0-9] {% id %} HEXDIG -> [0-9A-Fa-f] {% id %} IPv6_hex -> HEXDIG | - ( HEXDIG HEXDIG ) | - ( HEXDIG HEXDIG HEXDIG ) | - ( HEXDIG HEXDIG HEXDIG HEXDIG ) + HEXDIG HEXDIG | + HEXDIG HEXDIG HEXDIG | + HEXDIG HEXDIG HEXDIG HEXDIG -IPv6_full -> IPv6_hex times_7[":" IPv6_hex] - {% (d) => flat_string(d) %} +ip6_full -> IPv6_hex times_7[":" IPv6_hex] {% flatten %} -IPv6_comp -> (IPv6_hex times_5[":" IPv6_hex]):? "::" - (IPv6_hex times_5[":" IPv6_hex]):? - {% (d) => flat_string(d) %} +ip6_compressed -> "::" {% flatten %} | + "::" IPv6_hex {% flatten %} | + IPv6_hex (":" IPv6_hex):* "::" IPv6_hex (":" IPv6_hex):* {% flatten %} -IPv6v4_full -> IPv6_hex times_5[":" IPv6_hex] ":" ip4 - {% (d) => flat_string(d) %} +IPv6v4_full -> IPv6_hex times_5[":" IPv6_hex] ":" ip4 {% flatten %} IPv6v4_comp -> (IPv6_hex times_3[":" IPv6_hex]):? "::" - (IPv6_hex times_3[":" IPv6_hex] ":"):? - ip4 - {% (d) => flat_string(d) %} + (IPv6_hex times_3[":" IPv6_hex] ":"):? ip4 {% flatten %} # Whitespace: `_` is optional, `__` is mandatory. _ -> wschar:* {% function(d) {return null;} %} @@ -119,21 +102,21 @@ ws -> wschar:* {% id %} wschar -> [ \t\n\r\v\f] {% id %} @{% -function flat_string(d) { +function flatten (d) { if (!d) return '' if (Array.isArray(d)) return d.flat(Infinity).join('') return d } -function ttlAsObject (d) { +function asTTL (d) { return { $TTL: d[2] } } -function originAsObject (d) { +function asOrigin (d) { return { $ORIGIN: d[2] } } -function toResourceRecord (d) { +function asRR (d) { const r = { name: d[0], ttl : d[1] ? d[1][1] : d[1], @@ -161,20 +144,23 @@ function toResourceRecord (d) { case 'NS': r.dname = d[6] break + case 'PTR': + 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.comment.serial = flatten(d[13]) r.refresh = d[15] - r.comment.refresh = flat_string(d[16]) + r.comment.refresh = flatten(d[16]) r.retry = d[18] - r.comment.retry = flat_string(d[19]) + r.comment.retry = flatten(d[19]) r.expire = d[21] - r.comment.expire = flat_string(d[22]) + r.comment.expire = flatten(d[22]) r.minimum = d[24] - r.comment.minimum = flat_string(d[25]) + r.comment.minimum = flatten(d[25]) break case 'TXT': r.data = d[6].map(e => e[0]) diff --git a/test/fixtures/zones/0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa b/test/fixtures/zones/0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa new file mode 100644 index 0000000..cc525cc --- /dev/null +++ b/test/fixtures/zones/0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa @@ -0,0 +1,11 @@ +;; reverse zone file for 127.0.0.1 and ::1 +$TTL 1814400 ; 3 weeks +@ 1814400 IN SOA localhost. root.localhost. ( + 1999010100 ; serial + 10800 ; refresh (3 hours) + 900 ; retry (15 minutes) + 604800 ; expire (1 week) + 86400 ; minimum (1 day) + ) +@ 1814400 IN NS localhost. +1 1814400 IN PTR localhost. \ No newline at end of file diff --git a/test/fixtures/zones/0.0.127.in-addr.arpa b/test/fixtures/zones/0.0.127.in-addr.arpa new file mode 100644 index 0000000..cc525cc --- /dev/null +++ b/test/fixtures/zones/0.0.127.in-addr.arpa @@ -0,0 +1,11 @@ +;; reverse zone file for 127.0.0.1 and ::1 +$TTL 1814400 ; 3 weeks +@ 1814400 IN SOA localhost. root.localhost. ( + 1999010100 ; serial + 10800 ; refresh (3 hours) + 900 ; retry (15 minutes) + 604800 ; expire (1 week) + 86400 ; minimum (1 day) + ) +@ 1814400 IN NS localhost. +1 1814400 IN PTR localhost. \ No newline at end of file diff --git a/test/fixtures/zones/example.com b/test/fixtures/zones/example.com new file mode 100644 index 0000000..c2db598 --- /dev/null +++ b/test/fixtures/zones/example.com @@ -0,0 +1,17 @@ +$ORIGIN example.com. ; designates the start of this zone file in the namespace +$TTL 3600 ; default expiration time (in seconds) of all RRs without their own TTL value +example.com. IN SOA ns.example.com. username.example.com. ( 2020091025 7200 3600 1209600 3600 ) +example.com. IN NS ns ; ns.example.com is a nameserver for example.com +example.com. IN NS ns.somewhere.example. ; ns.somewhere.example is a backup nameserver for example.com +example.com. IN MX 10 mail.example.com. ; mail.example.com is the mailserver for example.com +@ IN MX 20 mail2.example.com. ; equivalent to above line, "@" represents zone origin +@ IN MX 50 mail3 ; equivalent to above line, but using a relative host name +example.com. IN A 192.0.2.1 ; IPv4 address for example.com + IN AAAA 2001:db8:10::1 ; IPv6 address for example.com +ns IN A 192.0.2.2 ; IPv4 address for ns.example.com + IN AAAA 2001:db8:10::2 ; IPv6 address for ns.example.com +www IN CNAME example.com. ; www.example.com is an alias for example.com +wwwtest IN CNAME www ; wwwtest.example.com is another alias for www.example.com +mail IN A 192.0.2.3 ; IPv4 address for mail.example.com +mail2 IN A 192.0.2.4 ; IPv4 address for mail2.example.com +mail3 IN A 192.0.2.5 ; IPv4 address for mail3.example.com \ No newline at end of file diff --git a/test/fixtures/zones/localhost b/test/fixtures/zones/localhost new file mode 100644 index 0000000..cc0309f --- /dev/null +++ b/test/fixtures/zones/localhost @@ -0,0 +1,11 @@ +$ORIGIN localhost. +@ 86400 IN SOA @ root ( + 1999010100 ; serial + 10800 ; refresh (3 hours) + 900 ; retry (15 minutes) + 604800 ; expire (1 week) + 86400 ; minimum (1 day) + ) +@ 86400 IN NS @ +@ 86400 IN A 127.0.0.1 +@ 86400 IN AAAA ::1 \ No newline at end of file diff --git a/test/index.js b/test/index.js index 2f203d2..7748678 100644 --- a/test/index.js +++ b/test/index.js @@ -87,7 +87,7 @@ describe('parseZoneFile', function () { }) }) - it('parses a CNAME line', async () => { + it('parses a CNAME line, absolute', async () => { const r = await zv.parseZoneFile(`www 28800 IN CNAME vhost0.theartfarm.com.\n`) // console.dir(r, { depth: null }) assert.deepStrictEqual(r[0], { @@ -99,6 +99,18 @@ describe('parseZoneFile', function () { }) }) + it('parses a CNAME line, relative', async () => { + const r = await zv.parseZoneFile(`$ORIGIN theartfarm.com.\nwww 28800 IN CNAME vhost0\n`).then(zv.expandShortcuts) + // console.dir(r, { depth: null }) + assert.deepStrictEqual(r[0], new RR.CNAME({ + name : 'www.theartfarm.com.', + 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 }) @@ -134,6 +146,18 @@ describe('parseZoneFile', function () { }) }) }) + + it('parses the example.com zone file', async () => { + const file = './test/fixtures/zones/example.com' + fs.readFile(file, (err, buf) => { + if (err) throw err + + zv.parseZoneFile(buf.toString()).then(zv.expandShortcuts).then(r => { + // console.dir(r, { depth: null }) + assert.equal(r.length, 14) + }) + }) + }) }) describe('expandShortcuts', function () {