diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 53e71ae..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -# These are supported funding model platforms - -github: msimerson diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7a266d6..10e1cfa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,3 +11,7 @@ jobs: publish: uses: NicTool/.github/.github/workflows/publish.yml@main secrets: inherit + permissions: + contents: read + packages: write + id-token: write diff --git a/.gitignore b/.gitignore index de208cf..fac4529 100644 --- a/.gitignore +++ b/.gitignore @@ -15,14 +15,11 @@ pids *.seed *.pid.lock -# Directory for instrumented libs generated by jscoverage/JSCover +# Test coverage reporters lib-cov - -# Coverage directory used by tools like istanbul coverage *.lcov - -# nyc test coverage +lcov.info .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) diff --git a/.release b/.release index a6911a9..0512cc8 160000 --- a/.release +++ b/.release @@ -1 +1 @@ -Subproject commit a6911a90f1b15486fb319d844341421c78035b2a +Subproject commit 0512cc83f7b2b50ca01b78299b7b2a18ca4f3e66 diff --git a/CHANGELOG.md b/CHANGELOG.md index e9320f6..13fae61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,33 @@ ### Unreleased +### [1.2.0] - 2026-04-19 + +- feat(index): add tokenizeQuoted, ~10x faster char/quote/comments +- feat(zone): this.recordKeys Set for O(1) duplicate detection +- feat(maradns): parse CAA records from RAW +- feat(bind): support generic record format, RFC 3597 +- feat(bind): support recursive INCLUDE directive +- fix(dns-zone): in invalid arg, show usage +- fix(bind): better error message when SOA is missing fields +- fix(bind): don't limit TTL to 5 num chars +- fix(bind): add path traversal guard +- fix: two typos uncovered by testing +- change: replace some regex with native string functions +- change: lib/{bind,maradns,tinydns}.js — added ctx parameter (default: zoneOpts) +- change: class ZONE no longer extends Map +- change: iterate directly on strings +- change: serialByDate, remove first unused param (start) +- change: str.substr() -> str.slice +- deps: bump versions to latest +- doc(README): wordsmithed +- test: convert test runner & coverage to node:test +- test: add tinydns end-to-end coverage + ### [1.1.8] - 2026-04-07 - style: replace str.substring (deprecated) with slice +- style: use optional chaining ### [1.1.7] - 2026-03-13 @@ -197,3 +221,4 @@ [1.1.6]: https://github.com/NicTool/dns-zone/releases/tag/v1.1.6 [1.1.7]: https://github.com/NicTool/dns-zone/releases/tag/v1.1.7 [1.1.8]: https://github.com/NicTool/dns-zone/releases/tag/v1.1.8 +[1.2.0]: https://github.com/NicTool/dns-zone/releases/tag/v1.2.0 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ea5d711..084bc82 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,7 +2,7 @@ This handcrafted artisanal software is brought to you by: -|
msimerson (28) | +|
msimerson (29) | | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | this file is generated by [.release](https://github.com/msimerson/.release). diff --git a/README.md b/README.md index 3c68549..8578bfb 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,35 @@ # dns-zone -DNS zone tool +Import, export, and validate DNS zone data across common nameserver formats. ## SYNOPSIS -Import and export DNS data to and from common nameserver formats. Normalize, validate, and optionally apply transformations at the same time. +Parse and emit DNS zone data in BIND, tinydns, and maradns formats. Normalize (expand `@`, inherit TTLs, fully-qualify names), validate (RFC 1034/1035/2181/4035 coexistence rules), and convert between formats. +## INSTALLATION + +``` +npm install -g @nictool/dns-zone # CLI +npm install @nictool/dns-zone # library ``` -➜ ./bin/dns-zone.js -h + +## SUPPORTED FORMATS + +| Format | Import | Export | +| ------- | :----: | :-----------: | +| BIND | yes | yes | +| tinydns | yes | yes | +| maradns | yes | yes | +| JSON | yes | yes | +| human | n/a | yes (default) | + +BIND `$INCLUDE` directives are followed (paths are confined to the source file's directory). + +## CLI + +``` +➜ dns-zone -h +-+-+-+ +-+-+-+-+ |D|N|S| |Z|O|N|E| @@ -39,154 +60,97 @@ Misc -v, --verbose Show status messages during processing -h, --help Display this usage guide - -Examples - - 1. BIND file to human ./bin/dns-zone.js -i bind -f isi.edu - 2. BIND file to tinydns ./bin/dns-zone.js -i bind -f isi.edu -e tinydns - 3. tinydns file to BIND ./bin/dns-zone.js -i tinydns -f data -e bind - - Project home: https://github.com/NicTool/dns-zone ``` -## bin/dns-zone.js +### Examples -#### import from STDIN to human +Default human output: ``` -➜ cat example.com| ./bin/dns-zone.js -i bind -f - --origin=example.com. +➜ cat example.com | dns-zone -i bind -f - --origin=example.com. $ORIGIN example.com. $TTL 3600 example.com. 3600 SOA ns.example.com. username.example.com. 2020091025 7200 3600 1209600 3600 example.com. 3600 NS ns.example.com. -example.com. 3600 NS ns.somewhere.example. example.com. 3600 MX 10 mail.example.com. -example.com. 3600 MX 20 mail2.example.com. -example.com. 3600 MX 50 mail3.example.com. example.com. 3600 A 192.0.2.1 -example.com. 3600 AAAA 2001:0db8:0010:0000:0000:0000:0000:0001 -ns.example.com. 3600 A 192.0.2.2 -ns.example.com. 3600 AAAA 2001:0db8:0010:0000:0000:0000:0000:0002 www.example.com. 3600 CNAME example.com. -wwwtest.example.com. 3600 CNAME www.example.com. mail.example.com. 3600 A 192.0.2.3 -mail2.example.com. 3600 A 192.0.2.4 -mail3.example.com. 3600 A 192.0.2.5 ``` -#### from bind file to bind +Convert BIND → tinydns: ``` -➜ ./bin/dns-zone.js -i bind -e bind -f isi.edu --origin=isi.edu. -isi.edu. 60 IN SOA venera.isi.edu. action\.domains.isi.edu. 20 7200 600 3600000 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.isi.edu. 60 IN A 26.3.0.103 -venera.isi.edu. 60 IN A 10.1.0.52 -venera.isi.edu. 60 IN A 128.9.0.32 -vaxa.isi.edu. 60 IN A 10.2.0.27 -vaxa.isi.edu. 60 IN A 128.9.0.33 +➜ dns-zone --origin=isi.edu. -i bind -e tinydns -f isi.edu +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:10:60:: ++a.isi.edu:26.3.0.103:60:: ``` -#### from bind to bind (relative) +Render BIND relative to origin (hide ttl/class/origin/same-owner): ``` -➜ ./bin/dns-zone.js -i bind -e bind -f isi.edu --ttl=60 \ - --origin=isi.edu. --hide-ttl --hide-class --hide-origin --hide-same-owner -@ 60 IN SOA venera action\.domains 20 7200 600 3600000 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 - A 128.9.0.32 -vaxa A 10.2.0.27 - A 128.9.0.33 +➜ dns-zone -i bind -e bind -f isi.edu --origin=isi.edu. \ + --hide-ttl --hide-class --hide-origin --hide-same-owner +@ SOA venera action\.domains 20 7200 600 3600000 60 + NS a + NS venera +a A 26.3.0.103 +venera A 10.1.0.52 + A 128.9.0.32 ``` -#### from bind to tinydns +## PROGRAMMATIC API -``` -➜ ./bin/dns-zone.js --origin=isi.edu. -i bind -e tinydns -f isi.edu -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:: -``` +```js +import { bind, json, maradns, tinydns } from '@nictool/dns-zone' + +// BIND zone file → array of RR objects +const rrs = await bind.parseZoneFile(zoneText, { origin: 'example.com.', ttl: 3600 }) -#### from bind to maradns +// BIND zone file with $INCLUDE directives (pass file path so includes can be resolved) +const rrs = await bind.parseZoneFile(zoneText, { file: '/path/to/zone.db' }) +// JSON (NDJSON, one RR per line — same format as -e json output) +const rrs = await json.parseZoneFile(ndjsonText) + +// tinydns data file +const rrs = await tinydns.parseData(dataText) + +// maradns csv2 +const rrs = await maradns.parseZoneFile(csv2Text, { origin: 'example.com.' }) ``` -./bin/dns-zone.js -i bind -e maradns -f isi.edu --origin=isi.edu. -isi.edu. SOA venera.isi.edu. action\.domains.isi.edu. 20 7200 600 3600000 60 ~ -isi.edu. +60 NS a.isi.edu. ~ -isi.edu. +60 NS venera.isi.edu. ~ -isi.edu. +60 NS vaxa.isi.edu. ~ -isi.edu. +60 MX 10 venera.isi.edu. ~ -isi.edu. +60 MX 20 vaxa.isi.edu. ~ -a.isi.edu. +60 A 26.3.0.103 ~ -venera.isi.edu. +60 A 10.1.0.52 ~ -venera.isi.edu. +60 A 128.9.0.32 ~ -vaxa.isi.edu. +60 A 10.2.0.27 ~ -vaxa.isi.edu. +60 A 128.9.0.33 ~ + +Each RR is a [`@nictool/dns-resource-record`](https://github.com/NicTool/dns-resource-record) instance; use `rr.toBind()`, `rr.toTinydns()`, `rr.toMaraDNS()` to emit in other formats. + +Zone-level validation: + +```js +import ZONE from '@nictool/dns-zone/lib/zone.js' + +const z = new ZONE({ origin: 'example.com.', RR: rrs }) +if (z.errors.length) console.error(z.errors) ``` ## VALIDATION -DNS zones have numerous rules regarding the records that can exist in them. Examples: - -- [ ] serial numbers must increment when changes are made -- [x] multiple identical RRs are not allowed - RFC 2181 - - [x] CAA takes tag into account, SRV: port -- [x] RFC 2181: RR sets (identical label, class, type) must have identical TTL -- [x] multiple CNAMES with the same name are not allowed -- [x] CNAME label cannot coexist except for SIG,NXT,KEY,RRSIG,NSEC -- [ ] MX and NS records cannot point to CNAME - -Etc, etc, etc.. - -This module will input a collection of [dns-resource-records](https://github.com/NicTool/dns-resource-record) and validate that all the zone records can coexist. - -## TODO - -- importing - - [x] write a bind zone file parser - - [x] write a tinydns data file parser - - [x] add BIND parsing for all RRs supported by dns-rr - - [x] write a maradns parser -- normalize BIND 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 - - expand hostnames to FQDNs - - [x] ALL: name field - - [x] MX: exchange - - [x] CNAME: cname, - - [x] SOA: mname, rname, - - [x] NS,PTR: dname - - [x] suppress hostname when identical to previous RR -- [x] validate zone rules -- [x] make it easy to add test cases: eg, test/fixtures/zones - -## GOALS - -- 2040 compatibility - - the software stack should evolve gracefully with the tech industry - - loosely coupled dependencies -- modularity - - easy to add a new DNS [resource record type](https://github.com/NicTool/dns-resource-record) - - easy to add/modify/update DNS [zone rules](https://github.com/NicTool/dns-zone) -- easily coupled with many DNS servers -- distribution of DNS data should be secure, fast, and efficient +`ZONE` enforces the following rules on the records you feed it: + +- single SOA per zone (RFC 1035) +- single zone class across all records +- no duplicate RRs, including tag-aware CAA and port-aware SRV (RFC 2181) +- identical TTL across an RRset (same label/class/type) (RFC 2181) +- at most one CNAME per owner (RFC 1034) +- CNAME coexists only with SIG, NXT, KEY, NSEC, RRSIG (RFC 1034, 2181, 4035) + +Violations are collected on `zone.errors` and also printed by the CLI (with `-v` to include the offending record). + +## RELATED PACKAGES + +- [`@nictool/dns-resource-record`](https://github.com/NicTool/dns-resource-record) — record-level parsing, validation, and format conversion +- [`@nictool/dns-nameserver`](https://github.com/NicTool/dns-nameserver) — nameserver config parsers (BIND, Knot, MaraDNS, NSD, tinydns) + +## LICENSE + +BSD-3-Clause — see [LICENSE](LICENSE). diff --git a/bin/dns-zone.js b/bin/dns-zone.js index 73fe972..ec4b30c 100755 --- a/bin/dns-zone.js +++ b/bin/dns-zone.js @@ -1,25 +1,31 @@ #!node import fs from 'node:fs/promises' -import path from 'node:path' import os from 'node:os' +import path from 'node:path' +import * as RR from '@nictool/dns-resource-record' import chalk from 'chalk' import cmdLineArgs from 'command-line-args' import cmdLineUsage from 'command-line-usage' -import ZONE from '../lib/zone.js' import * as dz from '../index.js' import * as bind from '../lib/bind.js' -import * as tinydns from '../lib/tinydns.js' +import * as json from '../lib/json.js' import * as maradns from '../lib/maradns.js' - -import * as RR from '@nictool/dns-resource-record' +import * as tinydns from '../lib/tinydns.js' +import ZONE from '../lib/zone.js' const rr = new RR.A(null) // CLI argument processing -const opts = cmdLineArgs(usageOptions())._all +let opts +try { + opts = cmdLineArgs(usageOptions())._all +} catch (e) { + console.error(e.message) + usage(1) +} if (opts.verbose) console.error(opts) if (opts.help) usage(0) @@ -40,38 +46,43 @@ Object.assign(maradns.zoneOpts, optsObj) if (opts.verbose) console.error(bind.zoneOpts) -ingestZoneData() - .then(async (r) => { - switch (r.type) { - case 'tinydns': - return tinydns.parseData(r.data).then(checkZone) - case 'maradns': - maradns.zoneOpts.serial = await dz.serialByFileStat(opts.file) - return maradns.parseZoneFile(r.data).then(checkZone) - default: - return bind.parseZoneFile(r.data).then(checkZone) - } - }) - .then(output) - .catch((e) => { - console.error(e.message) - }) +try { + const r = await ingestZoneData() + let zoneArray + switch (r.type) { + case 'json': + zoneArray = checkZone(await json.parseZoneFile(r.data)) + break + case 'tinydns': + zoneArray = checkZone(await tinydns.parseData(r.data)) + break + case 'maradns': + maradns.zoneOpts.serial = await dz.serialByFileStat(opts.file) + zoneArray = checkZone(await maradns.parseZoneFile(r.data)) + break + default: + zoneArray = checkZone(await bind.parseZoneFile(r.data)) + } + output(zoneArray) +} catch (e) { + console.error(e.message) + process.exitCode = 1 +} function checkZone(zoneArray) { - return new Promise((resolve, reject) => { - try { - new ZONE({ - ttl: optsObj.ttl, - origin: optsObj.origin, - RR: zoneArray, - }) - // console.log(z) - resolve(zoneArray) - } catch (e) { - console.error(e) - reject(e) - } + const z = new ZONE({ + ttl: optsObj.ttl, + origin: optsObj.origin, + RR: zoneArray, }) + if (z.errors.length) { + for (const { rr, error } of z.errors) { + console.error(error.message) + if (opts.verbose) console.error(rr) + } + throw new Error(`zone validation failed: ${z.errors.length} error(s)`) + } + return zoneArray } function usage(code) { @@ -229,31 +240,24 @@ function usageSections() { ] } -function ingestZoneData() { - return new Promise((resolve, reject) => { - if (!opts.import) usage(1) - if (!opts.file) usage(1) - - let filePath = opts.file - - if (filePath === '-') { - filePath = '/dev/stdin' // process.stdin.fd - } else { - if (!bind.zoneOpts.origin) bind.zoneOpts.origin = rr.fullyQualify(path.basename(filePath)) - } +async function ingestZoneData() { + if (!opts.import) usage(1) + if (!opts.file) usage(1) - if (opts.verbose) console.error(`reading file ${filePath}`) + let raw + if (opts.file === '-') { + if (opts.verbose) console.error('reading from stdin') + const chunks = [] + for await (const chunk of process.stdin) chunks.push(chunk) + raw = Buffer.concat(chunks).toString() + } else { + if (!bind.zoneOpts.origin) bind.zoneOpts.origin = rr.fullyQualify(path.basename(opts.file)) + bind.zoneOpts.file = opts.file + if (opts.verbose) console.error(`reading file ${opts.file}`) + raw = await fs.readFile(opts.file, 'utf8') + } - fs.readFile(filePath) - .then(async (buf) => { - const str = await bind.includeIncludes(buf.toString(), opts) - resolve({ - type: opts.import, - data: str, - }) - }) - .catch(reject) - }) + return { type: opts.import, data: raw } } function output(zoneArray) { @@ -277,6 +281,7 @@ function isBlank(rr) { process.stdout.write(rr) return true } + return false } function toBind(zoneArray, origin) { @@ -307,17 +312,17 @@ function toTinydns(zoneArray) { function toJSON(zoneArray) { for (const rr of zoneArray) { if (isBlank(rr)) continue + if (!rr.get) continue // skip $TTL, $ORIGIN directives if (rr.get('comment')) rr.delete('comment') - process.stdout.write(JSON.stringify(Object.fromEntries(rr))) + process.stdout.write(JSON.stringify(Object.fromEntries(rr)) + '\n') } } function toHuman(zoneArray) { const widest = { owner: 0, ttl: 0, type: 0, rdata: 0 } const fields = ['owner', 'ttl', 'type'] - zoneArray.map((r) => { - if (r === os.EOL) return - if (!r.get) return + for (const r of zoneArray) { + if (r === os.EOL || !r.get) continue for (const f of fields) { if (getWidth(r.get(f)) > widest[f]) widest[f] = getWidth(r.get(f)) } @@ -326,7 +331,7 @@ function toHuman(zoneArray) { .map((f) => r.get(f)) .join(' ').length if (rdataLen > widest.rdata) widest.rdata = rdataLen - }) + } // console.log(widest) let rdataWidth = process.stdout.columns - widest.owner - widest.type - 10 @@ -339,22 +344,21 @@ function toHuman(zoneArray) { continue } - process.stdout.write(r.get('owner').padEnd(widest.owner + 2, ' ')) - + let line = r.get('owner').padEnd(widest.owner + 2, ' ') if (!bind.zoneOpts.hide.ttl) { - process.stdout.write(r.get('ttl').toString().padStart(widest.ttl, ' ') + ' ') + line += r.get('ttl').toString().padStart(widest.ttl, ' ') + ' ' } - - process.stdout.write(r.get('type').padEnd(widest.type + 2, ' ')) + line += r.get('type').padEnd(widest.type + 2, ' ') const rdata = r .getRdataFields() .map((f) => r.get(f)) .join(' ') - process.stdout.write(rdata.slice(0, rdataWidth)) - if (rdata.length > rdataWidth) process.stdout.write('...') + line += rdata.slice(0, rdataWidth) + if (rdata.length > rdataWidth) line += '...' + line += '\n' - process.stdout.write('\n') + process.stdout.write(line) } } @@ -374,7 +378,6 @@ function toMaraDNS(zoneArray) { } function getWidth(str) { - // console.log(str) - if ('number' === typeof str) return str.toString().length + if (typeof str === 'number') return str.toString().length return str.length } diff --git a/index.js b/index.js index 87d0ce2..b5e9535 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,15 @@ -import fs from 'fs/promises' +import fs from 'node:fs/promises' import bind from './lib/bind.js' +import json from './lib/json.js' import maradns from './lib/maradns.js' import tinydns from './lib/tinydns.js' -export { bind, maradns, tinydns } +export { bind, json, maradns, tinydns } export function valueCleanup(str) { if (str.startsWith('"') && str.endsWith('"')) { - str = str.substr(1, str.length - 2) // strip double quotes + str = str.slice(1, -1) } if (/^[0-9.]+$/.test(str) && Number(str).toString() === str) { @@ -19,58 +20,60 @@ export function valueCleanup(str) { } export function hasUnquoted(str, quoteChar, matchChar) { - let inQuotes = false - for (const c of str.split('')) { - if (c === quoteChar) inQuotes = !inQuotes - if (c === matchChar && !inQuotes) return true + if (!str.includes(quoteChar)) return str.includes(matchChar) + + const segs = str.split(quoteChar) + for (let i = 0; i < segs.length; i += 2) { + if (segs[i].includes(matchChar)) return true } return false } export function removeChar(str, quoteChar, matchChar) { - let r = '' - let inQuotes = false - for (const c of str.split('')) { - if (c === quoteChar) inQuotes = !inQuotes - if (c === matchChar && !inQuotes) continue - r += c + if (!str.includes(quoteChar)) return str.replaceAll(matchChar, '') + + const segs = str.split(quoteChar) + for (let i = 0; i < segs.length; i += 2) { + segs[i] = segs[i].replaceAll(matchChar, '') } - return r + return segs.join(quoteChar) } export function replaceChar(str, quoteChar, matchChar, replace) { - let r = '' - let inQuotes = false - for (const c of str.split('')) { - if (c === quoteChar) inQuotes = !inQuotes - if (c === matchChar && !inQuotes) { - r += replace - continue - } - r += c + if (!str.includes(quoteChar)) return str.replaceAll(matchChar, replace) + + const segs = str.split(quoteChar) + for (let i = 0; i < segs.length; i += 2) { + segs[i] = segs[i].replaceAll(matchChar, replace) } - return r + return segs.join(quoteChar) } export function stripComment(str, quoteChar, startChar) { - let r = '' - let inQuotes = false - for (const c of str.split('')) { - if (c === quoteChar) inQuotes = !inQuotes - if (c === startChar && !inQuotes) return r // comment, ignore rest of line - r += c + if (!str.includes(quoteChar)) { + const idx = str.indexOf(startChar) + return idx === -1 ? str : str.slice(0, idx) + } + + const segs = str.split(quoteChar) + for (let i = 0; i < segs.length; i += 2) { + const idx = segs[i].indexOf(startChar) + if (idx !== -1) { + segs[i] = segs[i].slice(0, idx) + return segs.slice(0, i + 1).join(quoteChar) + } } - return r + return segs.join(quoteChar) } -export function serialByDate(start, inc) { +export function serialByDate(inc) { const d = new Date() const month = (d.getMonth() + 1).toString().padStart(2, '0') const day = d.getDate().toString().padStart(2, '0') const year = d.getFullYear() - const increment = (inc || '00').toString().padStart(2, '0') + const increment = (inc ?? '00').toString().padStart(2, '0') - return parseInt(`${year}${month}${day}${increment}`, 10) + return Number(`${year}${month}${day}${increment}`) } export async function serialByFileStat(filePath) { @@ -79,7 +82,7 @@ export async function serialByFileStat(filePath) { } export function toSeconds(str) { - if (/^[0-9]+$/.test(str)) return parseInt(str, 10) // all numeric + if (/^[0-9]+$/.test(str)) return Number(str) const re = /(?:([0-9]+)w)?(?:([0-9]+)d)?(?:([0-9]+)h)?(?:([0-9]+)m)?(?:([0-9]+)s)?/i const match = str.match(re) @@ -87,10 +90,10 @@ export function toSeconds(str) { const [weeks, days, hours, minutes, seconds] = match.slice(1) return ( - parseInt(weeks || 0) * 604800 + - parseInt(days || 0) * 86400 + - parseInt(hours || 0) * 3600 + - parseInt(minutes || 0) * 60 + - parseInt(seconds || 0) * 1 + Number(weeks ?? 0) * 604800 + + Number(days ?? 0) * 86400 + + Number(hours ?? 0) * 3600 + + Number(minutes ?? 0) * 60 + + Number(seconds ?? 0) ) } diff --git a/lib/bind.js b/lib/bind.js index 62c977b..fe658c7 100644 --- a/lib/bind.js +++ b/lib/bind.js @@ -1,6 +1,6 @@ -import fs from 'fs/promises' -import os from 'os' -import path from 'path' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' import * as RR from '@nictool/dns-resource-record' @@ -12,35 +12,36 @@ export const zoneOpts = {} export default { zoneOpts, parseZoneFile } +// build a list of RR types that dns-rr supports +const rrTypes = Object.entries(RR) + .filter(([k, v]) => typeof v === 'function' && k !== 'default') + .map(([k]) => k) + .sort((a, b) => b.length - a.length || a.localeCompare(b)) + .join('|') + const re = { - directive: /^\$([A-Za-z]+)\s+([^\s]+)\s*(?:([^\s]+)\s+)?(;.*)?$/, - ttl: '([0-9]{1,5})', + directive: /^\$(\w+)\s+([^\s;]+)/, + ttl: '([0-9]+)', classes: '(IN|CS|CH|HS|NONE|ANY)', - rrtypes: - '(A|AAAA|APL|CAA|CERT|CNAME|DHCID|DNAME|DNSKEY|DS|HINFO|HIP|IPSECKEY|KEY|KX|LOC|MD|MF|MX|NAPTR|NS|NSEC|NSEC3|NSEC3PARAM|NXT|OPENPGPKEY|PTR|RP|RRSIG|SIG|SMIMEA|SOA|SPF|SRV|SSHFP|SVCB|TLSA|TXT|URI|TYPE)', - blank: /^\s*?$/, - comment: /^\s*(?:\/\/|;)[^\r\n]*?$/, } -re.zoneRR = new RegExp(`^([^\\s]+)?\\s*${re.ttl}?\\s*${re.classes}?\\s+${re.rrtypes}\\s+(.*?)\\s*$`) +re.zoneRR = new RegExp(`^([^\\s]+)?\\s*${re.ttl}?\\s*${re.classes}?\\s+(${rrTypes}|TYPE[0-9]+)\\s+(.*)$`) + +export async function parseZoneFile(str, ctx = zoneOpts) { + if (ctx.file) str = await includeIncludes(str, ctx) -export async function parseZoneFile(str, implicitOrigin) { const res = [] const rrWIP = {} - let lineCount = 0 - for (let line of str.split(os.EOL)) { - lineCount++ - if (lineCount % 500 === 0) process.stdout.write('.') - - if (isBlank(line, res)) continue - if (isComment(line, res)) continue - if (isDirective(line, res)) continue + for (let line of str.split(/\r?\n/)) { + if (isBlank(line, res, ctx)) continue + if (isComment(line, res, ctx)) continue + if (isDirective(line, res, ctx)) continue line = dz.stripComment(line, '"', ';') if (Object.keys(rrWIP).length) { // a continuation started - resumeContinuation(line, rrWIP, res) + resumeContinuation(line, rrWIP, res, ctx) continue } @@ -49,19 +50,18 @@ export async function parseZoneFile(str, implicitOrigin) { const [owner, ttl, c, type, rdata] = match.slice(1) const iterRR = { - owner: owner ? owner.trim() : owner, - ttl: expandTTL(ttl), - class: (c ? c.trim().toUpperCase() : c) || 'IN', + owner: owner?.trim(), + ttl: expandTTL(ttl, ctx), + class: c?.trim().toUpperCase() || 'IN', type: type.trim().toUpperCase(), rdata: rdata.trim(), } - expandOwnerShortcuts(iterRR) - expandRdataShortcuts(iterRR) + expandShortcuts(iterRR, ctx) if (!dz.hasUnquoted(iterRR.rdata, '"', '(')) { // single-line RR - res.push(parseRR(iterRR)) + res.push(parseRR(iterRR, ctx)) continue } @@ -71,7 +71,7 @@ export async function parseZoneFile(str, implicitOrigin) { if (dz.hasUnquoted(iterRR.rdata, '"', ')')) { // this is a single line continuation, see also resumeContinuation iterRR.rdata = dz.removeChar(iterRR.rdata, '"', ')').trim() - res.push(parseRR(iterRR)) + res.push(parseRR(iterRR, ctx)) continue } @@ -81,160 +81,188 @@ export async function parseZoneFile(str, implicitOrigin) { return res } -function expandOwnerShortcuts(iterRR) { - if (iterRR.owner === '@') iterRR.owner = zoneOpts.origin +function expandShortcuts(iterRR, ctx) { + if (iterRR.owner === '@') iterRR.owner = ctx.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 (!iterRR.owner && zoneOpts.prevOwner) iterRR.owner = zoneOpts.prevOwner - if (!iterRR.owner) iterRR.owner = zoneOpts.origin + if (!iterRR.owner && ctx.prevOwner) iterRR.owner = ctx.prevOwner + if (!iterRR.owner) iterRR.owner = ctx.origin + + if (ctx.prevOwner !== iterRR.owner) ctx.prevOwner = iterRR.owner - if (zoneOpts.prevOwner !== iterRR.owner) zoneOpts.prevOwner = iterRR.owner + iterRR.owner = rr.fullyQualify(iterRR.owner, ctx.origin) - iterRR.owner = rr.fullyQualify(iterRR.owner, zoneOpts.origin) + expandRdataShortcuts(iterRR, ctx) } -function resumeContinuation(line, rrWIP, res) { +function resumeContinuation(line, rrWIP, res, ctx) { // within a zone file, new lines are ignored within parens. A paren was // opened, the closing paren will end the RR's rdata if (dz.hasUnquoted(line, '"', ')')) { // last line of this RR rrWIP.rdata += dz.removeChar(line, '"', ')') rrWIP.rdata = rrWIP.rdata.replace(/[\s]+/g, ' ') // flatten whitespace - res.push(parseRR(rrWIP)) - Object.keys(rrWIP).map((k) => delete rrWIP[k]) + res.push(parseRR(rrWIP, ctx)) + for (const k of Object.keys(rrWIP)) delete rrWIP[k] } else { rrWIP.rdata += line } } -function expandRdataShortcuts(iterRR) { +function expandRdataShortcuts(iterRR, ctx) { switch (iterRR.type) { case 'MX': case 'NS': case 'CNAME': case 'DNAME': - iterRR.rdata = rr.fullyQualify(iterRR.rdata, zoneOpts.origin) + iterRR.rdata = rr.fullyQualify(iterRR.rdata, ctx.origin) break } } -function expandTTL(str) { - if (!str) return zoneOpts.ttl +function expandTTL(str, ctx) { + if (!str) return ctx.ttl return dz.toSeconds(str.trim()) } -function isBlank(str, res) { - if (re.blank.test(str)) { - if (zoneOpts.showBlank) res.push(str) - return true - } - return false +function isBlank(str, res, ctx) { + if (str.trim() !== '') return false + if (ctx.showBlank) res.push(str) + return true } -function isComment(str, res) { - if (re.comment.test(str)) { - if (zoneOpts.showComment) res.push(str) - return true - } - return false +function isComment(str, res, ctx) { + const t = str.trimStart() + if (!t.startsWith(';') && !t.startsWith('//')) return false + if (ctx.showComment) res.push(str) + return true } -function isDirective(line, res) { +function isDirective(line, res, ctx) { const match = line.match(re.directive) if (!match) return false switch (match[1]) { case 'TTL': - zoneOpts.ttl = dz.valueCleanup(match[2]) - res.push({ $TTL: zoneOpts.ttl }) + ctx.ttl = dz.valueCleanup(match[2]) + res.push({ $TTL: ctx.ttl }) return true case 'ORIGIN': - zoneOpts.origin = rr.fullyQualify(dz.valueCleanup(match[2])) - res.push({ $ORIGIN: zoneOpts.origin }) + ctx.origin = rr.fullyQualify(dz.valueCleanup(match[2])) + res.push({ $ORIGIN: ctx.origin }) return true case 'INCLUDE': - return true + throw new Error(`$INCLUDE requires ctx.file to resolve paths: ${match[2]}`) } return false } -function parseRR(rr) { +function parseRR(rri, ctx) { try { - switch (rr.type) { + if (rri.type.startsWith('TYPE') && rri.type.length > 4) return parseGenericType(rri, ctx) + switch (rri.type) { case 'SOA': - return parseSOA(rr) + return parseSOA(rri, ctx) default: - return new RR[rr.type]({ bindline: `${rr.owner} ${rr.ttl} ${rr.class} ${rr.type} ${rr.rdata}` }) + return new RR[rri.type]({ bindline: `${rri.owner} ${rri.ttl} ${rri.class} ${rri.type} ${rri.rdata}` }) } } catch (e) { - console.error(rr) - // console.error(e.message) + e.rr = rri throw e } } -function parseSOA(rri) { +// RFC 3597 §5: parse \# generic rdata +function parseGenericRdata(rdataStr) { + const str = rdataStr.trim() + if (!str.startsWith('\\#')) return null + const rest = str.slice(2).trim() + const spaceIdx = rest.indexOf(' ') + const lenStr = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx) + const hexStr = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1).replace(/\s+/g, '') + const length = Number(lenStr) + if (!Number.isInteger(length) || length < 0) throw new Error(`RFC 3597: invalid rdata length: ${lenStr}`) + if (hexStr.length !== length * 2) { + throw new Error(`RFC 3597: rdata length mismatch: declared ${length} bytes, got ${hexStr.length / 2}`) + } + return { length, hex: hexStr.toLowerCase() } +} + +// RFC 3597 §5: handle TYPE generic type notation +function parseGenericType(rri, ctx) { + const typeNum = Number(rri.type.slice(4)) // TYPE65534 → 65534 + const knownName = RR.typeMap[typeNum] // e.g. 'A' for type 1, undefined if unknown + + if (rri.rdata.trim().startsWith('\\#')) { + const generic = parseGenericRdata(rri.rdata) // throws on malformed + // Known types in \# format are stored as plain objects — decoding wire format + // to type-specific text would require per-type wire parsers (RFC 3597 §5 note) + return { + owner: rri.owner, + ttl: rri.ttl, + class: rri.class, + type: knownName ?? rri.type, + rdata: `\\# ${generic.length}${generic.length ? ' ' + generic.hex : ''}`, + } + } + + // Normal text rdata — only valid for known types + if (!knownName || !RR[knownName]) { + throw new Error(`unknown TYPE number ${typeNum}: use \\# generic rdata format (RFC 3597)`) + } + if (knownName === 'SOA') return parseSOA({ ...rri, type: knownName }, ctx) + return new RR[knownName]({ bindline: `${rri.owner} ${rri.ttl} ${rri.class} ${knownName} ${rri.rdata}` }) +} + +function parseSOA(rri, ctx) { const todo = ['mname', 'rname', 'serial', 'refresh', 'retry', 'expire', 'minimum'] for (const v of rri.rdata.split(/[\s]+/)) { if (['(', ')'].includes(v)) continue - rri[todo.shift()] = /^[0-9]+$/.test(v) ? parseInt(v) : rr.fullyQualify(v, zoneOpts.origin) + rri[todo.shift()] = /^[0-9]+$/.test(v) ? Number(v) : rr.fullyQualify(v, ctx.origin) } if (todo.length !== 0) { - console.error(rri) - console.error('todo not done') - throw todo + throw new Error(`SOA missing fields: ${todo.join(', ')}`) } delete rri.rdata if (!rri.ttl) rri.ttl = rri.minimum const rrsoa = new RR.SOA(rri) - if (!zoneOpts.ttl || zoneOpts.ttl < rrsoa.get('minimum')) zoneOpts.ttl = rrsoa.get('minimum') + if (!ctx.ttl || ctx.ttl < rrsoa.get('minimum')) ctx.ttl = rrsoa.get('minimum') return rrsoa } -// TODO: integrate these remnants with parseZone -export async function expandShortcuts(zoneArray) { - const implicitOrigin = rr.fullyQualify(zoneOpts.origin) // zone 'name' in named.conf - let origin = implicitOrigin - // const empty = [ undefined, null, '' ] - - for (let i = 0; i < zoneArray.length; i++) { - const entry = zoneArray[i] - - // 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.$ORIGIN) { - // declared $ORIGIN in zone file - origin = rr.fullyQualify(entry.$ORIGIN, implicitOrigin) - continue - } - if (!origin) throw new Error(`zone origin ambiguous, cowardly bailing out`) - } -} - -export async function includeIncludes(str, opts) { - const parts = str.split(/^\$INCLUDE\s+([^\s]+)\s*([^\s]+\s*)?(?:;.*)?$/gm) - if (parts.length === 1) return str +export async function includeIncludes(str, opts, _seen = new Set()) { + const includeDir = path.resolve(path.dirname(opts.file)) + _seen.add(path.resolve(opts.file)) const result = [] - const includeDir = path.dirname(opts.file) + for (const line of str.split(/\r?\n/)) { + if (!line.trimStart().startsWith('$INCLUDE')) { + result.push(line) + continue + } - while (parts.length) { - const next = parts.shift() // line(s) preceding $INCLUDE - result.push(next.trim()) - if (parts.length === 0) break // recursion break + // Parse: $INCLUDE [origin] [; comment] + const tokens = line.trimStart().slice('$INCLUDE'.length).replace(/;.*$/, '').trim().split(/\s+/) + const requested = tokens[0] + const origin = tokens[1] - const includeFile = path.resolve(includeDir, parts.shift()) - const origin = parts.shift() - if (origin) result.push(`$ORIGIN: ${origin}\n`) - const contents = await fs.readFile(includeFile) - result.push(contents.toString()) + if (!requested) throw new Error(`$INCLUDE missing filename`) - if (parts.length === 0) break + const includeFile = path.resolve(includeDir, requested) + const rel = path.relative(includeDir, includeFile) + if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) { + throw new Error(`$INCLUDE path escapes include directory: ${requested}`) + } + if (_seen.has(includeFile)) { + throw new Error(`$INCLUDE cycle detected: ${includeFile}`) + } + if (origin) result.push(`$ORIGIN ${origin}`) + const contents = await fs.readFile(includeFile, 'utf8') + result.push(await includeIncludes(contents, { ...opts, file: includeFile }, _seen)) } - return result.join(os.EOL) + return result.join('\n') } diff --git a/lib/json.js b/lib/json.js new file mode 100644 index 0000000..327b23e --- /dev/null +++ b/lib/json.js @@ -0,0 +1,22 @@ +import * as RR from '@nictool/dns-resource-record' + +export const zoneOpts = {} + +export default { zoneOpts, parseZoneFile } + +export async function parseZoneFile(str, ctx = zoneOpts) { + const res = [] + + for (const line of str.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + + const obj = JSON.parse(trimmed) + if (!obj.type) throw new Error(`JSON record missing 'type': ${trimmed}`) + if (!RR[obj.type]) throw new Error(`unknown RR type: ${obj.type}`) + + res.push(new RR[obj.type](obj)) + } + + return res +} diff --git a/lib/maradns.js b/lib/maradns.js index 2ac7686..78f0e57 100644 --- a/lib/maradns.js +++ b/lib/maradns.js @@ -1,5 +1,3 @@ -import os from 'os' - import * as RR from '@nictool/dns-resource-record' import * as dz from '../index.js' @@ -13,26 +11,130 @@ export default { zoneOpts, parseZoneFile } const re = { zoneTTL: /^\/ttl\s+([0-9]{1,5})\s*~/, zoneOrigin: /^\/origin\s+([^\s]+)\s*~/, - zoneRR: - /^([^\s]+\s+)(\+[0-9]+\s+)?(IN\s+)?(?:(a|aaaa|fqdn4|fqdn6|hinfo|loc|mx|naptr|ns|ptr|raw|soa|srv|txt|spf|raw)\s+)(.*?)\s*~/i, - pipeDelim: - /^(?:([^|]+)\|)(?:\+([0-9]+)\|)?(?:(IN)\|)?(?:(a|aaaa|fqdn4|fqdn6|hinfo|loc|mx|naptr|ns|ptr|raw|soa|srv|txt|spf|raw)\|)?(.*)\|\s*~/i, - blank: /^\s*$/, - comment: /^\s*(?:#)[^\r\n]*?$/, } -export async function parseZoneFile(str) { +const pipeTypes = new Set([ + 'a', + 'aaaa', + 'fqdn4', + 'fqdn6', + 'hinfo', + 'loc', + 'mx', + 'naptr', + 'ns', + 'ptr', + 'raw', + 'soa', + 'srv', + 'txt', + 'spf', +]) + +function isDigits(s) { + if (!s.length) return false + for (let i = 0; i < s.length; i++) { + if (s[i] < '0' || s[i] > '9') return false + } + return true +} + +function parseZoneRR(line) { + const tilde = line.indexOf('~') + if (tilde === -1) return null + if (line.slice(0, tilde).includes('|')) return null // pipe-delimited; handled by parsePipeDelim + + let pos = 0 + const end = tilde + + function skipWS() { + while (pos < end && (line[pos] === ' ' || line[pos] === '\t')) pos++ + } + function readToken() { + skipWS() + const start = pos + while (pos < end && line[pos] !== ' ' && line[pos] !== '\t') pos++ + return line.slice(start, pos) + } + + const owner = readToken() + if (!owner) return null + + let ttl = null + let saved = pos + const tok1 = readToken() + if (tok1.startsWith('+') && isDigits(tok1.slice(1))) { + ttl = tok1.slice(1) + } else { + pos = saved + } + + let cls = null + saved = pos + const tok2 = readToken() + if (tok2.toUpperCase() === 'IN') { + cls = tok2 + } else { + pos = saved + } + + let type = null + saved = pos + const tok3 = readToken() + if (pipeTypes.has(tok3.toLowerCase())) { + type = tok3 + } else { + pos = saved + } + + skipWS() + const rdata = line.slice(pos, end) + return [null, owner, ttl, cls, type, rdata] +} + +function parsePipeDelim(line) { + const fields = line.split('|') + if (fields.length < 3) return null + if (fields.pop().trim() !== '~') return null + + let i = 0 + const owner = fields[i++] + if (!owner) return null + + let ttl = null + if (i < fields.length && fields[i].trimStart().startsWith('+')) { + ttl = fields[i].trim().slice(1) + i++ + } + + let cls = null + if (i < fields.length && fields[i].trim().toUpperCase() === 'IN') { + cls = fields[i].trim() + i++ + } + + let type = null + if (i < fields.length && pipeTypes.has(fields[i].trim().toLowerCase())) { + type = fields[i].trim() + i++ + } + + const rdata = fields.slice(i).join('|') + return [null, owner, ttl, cls, type, rdata] +} + +export async function parseZoneFile(str, ctx = zoneOpts) { const res = [] let rrWIP = '' - for (let line of str.split(os.EOL)) { - if (isBlank(line, res)) continue - if (isComment(line, res)) continue + for (let line of str.split(/\r?\n/)) { + if (isBlank(line, res, ctx)) continue + if (isComment(line, res, ctx)) continue line = dz.stripComment(line, "'", '#') - if (isZoneTTL(line, res)) continue - if (isZoneOrigin(line, res)) continue + if (isZoneTTL(line, res, ctx)) continue + if (isZoneOrigin(line, res, ctx)) continue if (rrWIP.length) { // a continuation was started @@ -52,23 +154,23 @@ export async function parseZoneFile(str) { continue } - let match = line.match(re.zoneRR) + let match = parseZoneRR(line) if (!match) { - match = line.match(re.pipeDelim) + match = parsePipeDelim(line) if (!match) throw new Error(`parse failure, unrecognized: ${line}`) } const [owner, ttl, c, type, rdata] = match.slice(1) const iterRR = { - owner: owner ? owner.trim() : owner, - ttl: parseInt(ttl ? ttl.trim() : ttl) || zoneOpts.ttl || 86400, - class: (c ? c.trim().toUpperCase() : c) || 'IN', - type: type ? type.trim().toUpperCase() : 'A', + owner: owner?.trim(), + ttl: parseInt(ttl?.trim()) || ctx.ttl || 86400, + class: c?.trim().toUpperCase() || 'IN', + type: type?.trim().toUpperCase() ?? 'A', rdata: rdata.trim(), } - if (iterRR.owner === '') iterRR.owner = zoneOpts.origin - expandPercent(iterRR, 'owner', zoneOpts.origin) + if (iterRR.owner === '') iterRR.owner = ctx.origin + expandPercent(iterRR, 'owner', ctx.origin) iterRR.rdata = dz.removeChar(iterRR.rdata, "'", '~').trim() switch (iterRR.type) { @@ -78,24 +180,24 @@ export async function parseZoneFile(str) { case 'NS': case 'SRV': case 'URI': - expandPercent(iterRR, 'rdata', zoneOpts.origin) + expandPercent(iterRR, 'rdata', ctx.origin) break case 'PTR': processPTR(iterRR) break case 'RAW': - processRaw(iterRR) - continue // TODO: this skips RAW + if (!processRaw(iterRR)) continue + break case 'HINFO': - iterRR.rdata = iterRR.rdata.split(/;/).join(' ').replace(/'/g, '"') + iterRR.rdata = iterRR.rdata.replaceAll(';', ' ').replaceAll("'", '"') break case 'NAPTR': processNAPTR(iterRR) break case 'SOA': iterRR.rdata = dz.replaceChar(iterRR.rdata, "'", '@', '.') - if (zoneOpts.serial) { - iterRR.rdata = iterRR.rdata.replace(/\/serial/, zoneOpts.serial) + if (ctx.serial) { + iterRR.rdata = iterRR.rdata.replace(/\/serial/, ctx.serial) } break case 'SPF': @@ -112,13 +214,11 @@ export async function parseZoneFile(str) { default: } - // console.log(iterRR) const asBind = `${iterRR.owner} ${iterRR.ttl} ${iterRR.class} ${iterRR.type} ${iterRR.rdata}` try { res.push(new RR[iterRR.type]({ bindline: asBind })) } catch (e) { - console.error(asBind) - // console.error(e) + e.rr = asBind throw e } } @@ -179,28 +279,56 @@ function processPTR(irr) { } function processNAPTR(rr) { - const match = rr.rdata.match(/([0-9]+)\s+([0-9]+)\s+('[^']*');('[^']*');('[^']*')\s+([^s]+)\s*/) + const match = rr.rdata.trim().match(/^([0-9]+)\s+([0-9]+)\s+('[^']*');('[^']*');('[^']*')\s+(\S+)$/) if (!match) throw new Error(`unable to parse NAPTR: ${rr.rdata}`) - rr.rdata = match.slice(1).join(' ').replace(/'/g, '"') - // console.log(match) + rr.rdata = match.slice(1).join(' ').replaceAll("'", '"') } function processRaw(irr) { - const [typeId, rest] = irr.rdata.match(/^\s*([0-9]+)\s(.*)\s*$/).slice(1) + const trimmed = irr.rdata.trim() + const spaceIdx = trimmed.indexOf(' ') + if (spaceIdx === -1) return false + const typeId = trimmed.slice(0, spaceIdx) + const rest = trimmed.slice(spaceIdx + 1).trim() switch (typeId) { - case 257: - case '257': + case '257': { + const wire = parseRawWire(rest) + const flags = wire[0] + const tagLen = wire[1] + const tag = wire.slice(2, 2 + tagLen).toString() + const value = wire.slice(2 + tagLen).toString() irr.type = 'CAA' - irr.rdata = rest + irr.rdata = `${flags} ${tag} "${value}"` + return true + } + default: + return false + } +} + +function parseRawWire(str) { + const bytes = [] + let i = 0 + while (i < str.length) { + if (str[i] === '\\' && str[i + 1] === 'x' && /^[0-9a-f]{2}$/i.test(str.slice(i + 2, i + 4))) { + bytes.push(parseInt(str.slice(i + 2, i + 4), 16)) + i += 4 + } else if (str[i] === "'") { + i += 1 + } else { + bytes.push(str.charCodeAt(i)) + i += 1 + } } + return Buffer.from(bytes) } function naturallyQuoted(str) { // untangle the bizarre forms of quoting Mara allows let r = '' let inQuotes = false - for (const c of str.split('')) { + for (const c of str) { if (c === "'") inQuotes = !inQuotes if (inQuotes && c !== "'") { r += c @@ -211,32 +339,28 @@ function naturallyQuoted(str) { return `"${r}"` } -function isBlank(str, res) { - if (re.blank.test(str)) { - if (zoneOpts.showBlank) res.push(str) - return true - } - return false +function isBlank(str, res, ctx) { + if (str.trim() !== '') return false + if (ctx.showBlank) res.push(str) + return true } -function isComment(str, res) { - if (re.comment.test(str)) { - if (zoneOpts.showComment) res.push(str) - return true - } - return false +function isComment(str, res, ctx) { + if (!str.trimStart().startsWith('#')) return false + if (ctx.showComment) res.push(str) + return true } -function isZoneOrigin(str, res) { +function isZoneOrigin(str, res, ctx) { const match = str.match(re.zoneOrigin) if (!match) return false - zoneOpts.origin = rr.fullyQualify(dz.valueCleanup(match[1])) + ctx.origin = rr.fullyQualify(dz.valueCleanup(match[1])) return true } -function isZoneTTL(str, res) { +function isZoneTTL(str, res, ctx) { const match = str.match(re.zoneTTL) if (!match) return false - zoneOpts.ttl = dz.valueCleanup(match[1]) + ctx.ttl = dz.valueCleanup(match[1]) return true } diff --git a/lib/tinydns.js b/lib/tinydns.js index 40e53c3..3862549 100644 --- a/lib/tinydns.js +++ b/lib/tinydns.js @@ -6,9 +6,17 @@ const rr = new RR.A(null) export const zoneOpts = {} +// djbdns tinydns-data SOA defaults — see https://cr.yp.to/djbdns/tinydns-data.html +const SOA_DEFAULTS = { + refresh: 16384, + retry: 2048, + expire: 1048576, + minimum: 2560, +} + export default { zoneOpts, parseData } -export async function parseData(str) { +export async function parseData(str, ctx = zoneOpts) { // https://cr.yp.to/djbdns/tinydns-data.html const rrs = [] let curLine @@ -18,15 +26,13 @@ export async function parseData(str) { curLine = line if (line === '') continue // "Blank lines are ignored" if (/^#/.test(line)) continue // "Comment line. The line is ignored." - switch ( - line[0] // first char of line - ) { + switch (line[0]) { case '%': // location break case '-': // ignored break case '.': // NS, A, SOA - rrs.push(...parseTinyDot(line)) + rrs.push(...parseTinyDot(line, ctx)) break case '&': // NS, A rrs.push(...parseTinyAmpersand(line)) @@ -50,7 +56,7 @@ export async function parseData(str) { rrs.push(new RR.CNAME({ tinyline: line })) break case 'Z': // SOA - rrs.push(new RR.SOA({ default: zoneOpts, tinyline: line })) + rrs.push(new RR.SOA({ default: ctx, tinyline: line })) break case ':': // generic rrs.push(parseTinyGeneric(line)) @@ -67,17 +73,16 @@ export async function parseData(str) { default: throw new Error(`garbage found in tinydns data: ${line}`) } - // console.log(line) } } catch (e) { - console.error(curLine) + e.line = curLine throw e } return rrs } -function parseTinyDot(str) { +function parseTinyDot(str, ctx) { /* * .fqdn:ip:x:ttl:timestamp:lo * an NS (``name server'') record showing x.ns.fqdn as a name server for fqdn; @@ -93,12 +98,12 @@ function parseTinyDot(str) { ttl: parseInt(ttl, 10), type: 'SOA', mname: rr.fullyQualify(/\./.test(mname) ? mname : `${mname}.ns.${fqdn}`), - rname: rr.fullyQualify(`hostmaster.{fqdn}`), - serial: zoneOpts.serial || zone.serialByDate(), - refresh: 16384, - retry: 2048, - expire: 1048576, - minimum: 2560, + rname: rr.fullyQualify(`hostmaster.${fqdn}`), + serial: ctx.serial || zone.serialByDate(), + refresh: SOA_DEFAULTS.refresh, + retry: SOA_DEFAULTS.retry, + expire: SOA_DEFAULTS.expire, + minimum: SOA_DEFAULTS.minimum, timestamp: parseInt(ts) || '', location: loc?.trim() || '', }), @@ -170,7 +175,7 @@ function parseTinyEquals(str) { const [fqdn, ip, ttl, ts, loc] = str.slice(1).split(':') rrs.push( new RR.PTR({ - owner: `${ip.split('.').reverse().join('.')}.in-addr.arpa`, + owner: `${ip.split('.').reverse().join('.')}.in-addr.arpa.`, ttl: parseInt(ttl, 10), type: 'PTR', dname: rr.fullyQualify(fqdn), @@ -206,9 +211,8 @@ function parseTinyAt(str) { 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.slice(1).split(':') + const rrs = [new RR.AAAA({ tinyline: `3${fqdn}:${rdata}:${ttl}:${ts ?? ''}:${loc ?? ''}` })] rrs.push( new RR.PTR({ @@ -226,7 +230,7 @@ function parseTinySix(str) { function parseTinyGeneric(str) { // generic, :fqdn:n:rdata:ttl:timestamp:lo - const [, n, , , ,] = str.slice(1).split(':') + const [, n] = str.slice(1).split(':') switch (parseInt(n, 10)) { case 13: diff --git a/lib/zone.js b/lib/zone.js index 952dee2..5bc5954 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -1,10 +1,12 @@ -export default class ZONE extends Map { +export default class ZONE { constructor(opts = {}) { - super() - this.RR = [] this.SOA = {} this.ownerIdx = {} + this.recordKeys = new Set() + this.errors = [] + this.origin = undefined + this.ttl = undefined if (opts.origin) this.setOrigin(opts.origin) this.setTTL(opts.ttl) @@ -14,8 +16,7 @@ export default class ZONE extends Map { try { this.addRR(r) } catch (e) { - console.error(r) - console.error(e) + this.errors.push({ rr: r, error: e }) } } } @@ -68,7 +69,7 @@ export default class ZONE extends Map { // in use, have SIG, NXT, and KEY RRs, but may have no other data. // RFC 4035: If a CNAME RRset is present at a name in a signed zone, // appropriate RRSIG and NSEC RRsets are REQUIRED at that name. - const compatibleTypes = 'SIG NXT KEY NSEC RRSIG'.split(' ') + const compatibleTypes = ['SIG', 'NXT', 'KEY', 'NSEC', 'RRSIG'] const conflicts = ownerMatches.filter((r) => { return !compatibleTypes.includes(r.get('type')) }).length @@ -78,34 +79,25 @@ export default class ZONE extends Map { } append(rr) { - // optimization: create owner index, so searches happen in O(1) vs O(n) time, - // matters when zone has 1000+ records + // owner index for O(1) owner lookup; recordKeys for O(1) duplicate detection const owner = rr.get('owner') - if (this.ownerIdx[owner] === undefined) this.ownerIdx[owner] = [] + this.ownerIdx[owner] ??= [] this.ownerIdx[owner].push(this.RR.length) + this.recordKeys.add(recordKey(rr)) this.RR.push(rr) } getRR(rr) { const fields = rr.getFields() - - return this.getOwnerMatches(rr).filter((r) => { - const fieldDiffs = fields - .map((f) => { - return r.get(f) === rr.get(f) - }) - .filter((m) => m === false).length - - if (!fieldDiffs) return r - }) + return this.getOwnerMatches(rr).filter((r) => fields.every((f) => r.get(f) === rr.get(f))) } hasNoConflictingLabels(rr) { const ownerMatches = this.getOwnerMatches(rr) if (ownerMatches.length === 0) return - const allowedTypes = 'CNAME SIG NXT KEY NSEC RRSIG'.split(' ') + const allowedTypes = ['CNAME', 'SIG', 'NXT', 'KEY', 'NSEC', 'RRSIG'] // CNAME conflicts with almost everything, assure no CNAME at this name if (!allowedTypes.includes(rr.get('type'))) { @@ -115,20 +107,15 @@ export default class ZONE extends Map { } isNotDuplicate(rr) { - if (this.getRR(rr).length) throw new Error('multiple identical RRs are not allowed, RFC 2181') + if (this.recordKeys.has(recordKey(rr))) { + throw new Error('multiple identical RRs are not allowed, RFC 2181') + } } itMatchesSetTTL(rr) { - // a RR Set exists...with the same label(owner), class, type (different data) - const matches = this.getOwnerMatches(rr).filter((r) => { - const diffs = ['class', 'type'] - .map((f) => { - return r.get(f) === rr.get(f) - }) - .filter((m) => m === false).length - - if (!diffs) return r - }) + const cls = rr.get('class') + const type = rr.get('type') + const matches = this.getOwnerMatches(rr).filter((r) => r.get('class') === cls && r.get('type') === type) if (!matches.length) return true if (matches[0].get('ttl') === rr.get('ttl')) return true throw new Error('Records with identical label, class, and type must have identical TTL, RFC 2181') @@ -136,31 +123,33 @@ export default class ZONE extends Map { getOwnerMatches(rr) { const owner = rr.get('owner') - - if (this.ownerIdx[owner] === undefined) return [] - - // optimized, retrieves matching owners via index - return this.ownerIdx[owner].map((n) => this.RR[n]) - - // not optimized, searches length of array for matches - // return this.RR.filter(r => { - // return r.get('owner') === owner - // }) + return (this.ownerIdx[owner] ?? []).map((n) => this.RR[n]) } setOrigin(val) { if (!val) throw new Error('origin is required!') - this.set('origin', val) + this.origin = val } setSOA(rr) { if (this.SOA.owner) throw new Error('Exactly one SOA RR should be present at the top!, RFC 1035') - rr.getFields().map((f) => (this.SOA[f] = rr.get(f))) + for (const f of rr.getFields()) { + this.SOA[f] = rr.get(f) + } + // SOA isn't appended to RR/ownerIdx, but track it for duplicate detection + this.recordKeys.add(recordKey(rr)) } setTTL(val) { if (!val) return - this.set('ttl', val) + this.ttl = val } } + +function recordKey(rr) { + return rr + .getFields() + .map((f) => `${f}=${rr.get(f)}`) + .join('|') +} diff --git a/package.json b/package.json index 09b8103..7f55454 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nictool/dns-zone", - "version": "1.1.8", + "version": "1.2.0", "description": "DNS Zone", "main": "index.js", "type": "module", @@ -13,15 +13,17 @@ "CHANGELOG.md" ], "scripts": { + "clean": "rm -rf coverage package-lock.json node_modules", + "format": "npm run prettier:fix && npm run lint:fix", "lint": "npx eslint *.js lib test", "lint:fix": "npx eslint --fix *.js lib test", "prettier": "npx prettier --ignore-path .gitignore --check .", "prettier:fix": "npx prettier --ignore-path .gitignore --write .", - "test": "npx mocha", + "test": "node --test", + "test:coverage": "node --test --experimental-test-coverage", + "test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info", "versions": "npx npm-dep-mgr check", - "versions:fix": "npx npm-dep-mgr update", - "format": "npm run prettier:fix && npm run lint:fix", - "test:coverage": "npx c8 --reporter=text --reporter=text-summary npm test" + "versions:fix": "npx npm-dep-mgr update" }, "repository": { "type": "git", @@ -45,14 +47,13 @@ "homepage": "https://github.com/NicTool/dns-zone#readme", "devDependencies": { "@eslint/js": "^10.0.1", - "eslint": "^10.0.3", - "globals": "^17.4.0", - "mocha": "11.7.5" + "eslint": "^10.2.1", + "globals": "^17.5.0" }, "dependencies": { - "command-line-args": "^6.0.1", + "command-line-args": "^6.0.2", "command-line-usage": "^7.0.4", - "@nictool/dns-resource-record": "^1.3.1" + "@nictool/dns-resource-record": "^1.6.0" }, "prettier": { "printWidth": 110, diff --git a/test/bind.js b/test/bind.js index 83ba437..8e8d60e 100644 --- a/test/bind.js +++ b/test/bind.js @@ -1,6 +1,7 @@ import assert from 'assert' import fs from 'fs/promises' import os from 'os' +import { describe, it, beforeEach } from 'node:test' import * as RR from '@nictool/dns-resource-record' import * as bind from '../lib/bind.js' @@ -509,6 +510,71 @@ describe('bind', function () { ) }) + // RFC 3597 — Handling of Unknown DNS Resource Record (RR) Types + // https://www.rfc-editor.org/rfc/rfc3597 + + it('RFC 3597: TYPE with text rdata maps to known type (TYPE1 → A)', async () => { + const r = await bind.parseZoneFile(`e.example.com. 3600 IN TYPE1 10.0.0.1\n`) + assert.deepStrictEqual( + r[0], + new RR.A({ owner: 'e.example.com.', ttl: 3600, class: 'IN', type: 'A', address: '10.0.0.1' }), + ) + }) + + it('RFC 3597: unknown TYPE with generic \\# rdata is stored as plain object', async () => { + const r = await bind.parseZoneFile(`a.example.com. 3600 IN TYPE65534 \\# 4 01020304\n`) + assert.deepStrictEqual(r[0], { + owner: 'a.example.com.', + ttl: 3600, + class: 'IN', + type: 'TYPE65534', + rdata: '\\# 4 01020304', + }) + }) + + it('RFC 3597: known TYPE with generic \\# rdata resolves type name, stores as plain object', async () => { + // TYPE1 = A; wire-format hex rdata is stored verbatim (no wire decoder available) + const r = await bind.parseZoneFile(`e.example.com. 3600 IN TYPE1 \\# 4 0a000001\n`) + assert.deepStrictEqual(r[0], { + owner: 'e.example.com.', + ttl: 3600, + class: 'IN', + type: 'A', + rdata: '\\# 4 0a000001', + }) + }) + + it('RFC 3597: TYPE \\# 0 (zero-length rdata)', async () => { + const r = await bind.parseZoneFile(`b.example.com. 3600 IN TYPE65534 \\# 0\n`) + assert.deepStrictEqual(r[0], { + owner: 'b.example.com.', + ttl: 3600, + class: 'IN', + type: 'TYPE65534', + rdata: '\\# 0', + }) + }) + + it('RFC 3597: multi-line generic rdata via BIND parenthesis continuation', async () => { + // example adapted from RFC 3597 §5 + const r = await bind.parseZoneFile(`a.example.com. 3600 IN TYPE65534 \\# 4 abcd (\n ef01 )\n`) + assert.deepStrictEqual(r[0], { + owner: 'a.example.com.', + ttl: 3600, + class: 'IN', + type: 'TYPE65534', + rdata: '\\# 4 abcdef01', + }) + }) + + it('RFC 3597: throws on generic rdata length mismatch', async () => { + // \# 4 declares 4 bytes but abcd is only 2 bytes of hex + await assert.rejects( + bind.parseZoneFile(`a.example.com. 3600 IN TYPE65534 \\# 4 abcd\n`), + /RFC 3597.*length mismatch/, + ) + }) + it('parses cadillac.net zone file', async () => { const file = './test/fixtures/bind/cadillac.net' const buf = await fs.readFile(file) @@ -542,5 +608,31 @@ describe('bind', function () { // console.dir(rrs, { depth: null }) assert.equal(rrs.length, 7) }) + + it('follows $INCLUDE when ctx.file is set', async () => { + const file = './test/fixtures/bind/example.net' + const buf = await fs.readFile(file) + const rrs = await bind.parseZoneFile(buf.toString(), { file }) + assert.equal(rrs.length, 7) + }) + + it('throws on $INCLUDE without ctx.file', async () => { + await assert.rejects( + bind.parseZoneFile('$INCLUDE non-existent.txt\n$TTL 3600\nexample.com. IN A 1.2.3.4\n'), + /\$INCLUDE requires ctx\.file/, + ) + }) + + it('throws on self-referencing $INCLUDE', async () => { + const file = './test/fixtures/bind/self-include' + const buf = await fs.readFile(file) + await assert.rejects(bind.includeIncludes(buf.toString(), { file }), /\$INCLUDE cycle detected/) + }) + + it('throws on mutually recursive $INCLUDE', async () => { + const file = './test/fixtures/bind/mutual-a' + const buf = await fs.readFile(file) + await assert.rejects(bind.includeIncludes(buf.toString(), { file }), /\$INCLUDE cycle detected/) + }) }) }) diff --git a/test/dns-zone.js b/test/dns-zone.js index 65c17fd..8b78b93 100644 --- a/test/dns-zone.js +++ b/test/dns-zone.js @@ -2,6 +2,7 @@ import assert from 'assert' import * as child from 'child_process' import path from 'path' import util from 'util' +import { describe, it } from 'node:test' const execFile = util.promisify(child.execFile) @@ -188,4 +189,78 @@ bounce.theartfarm.com.\t+86400\tCNAME\tcustom-email-domain.stripe.com. ~ assert.ifError(e) } }) + + it('exports BIND zone as bind format (-e bind)', async function () { + const binPath = path.resolve('bin', 'dns-zone.js') + const args = [ + binPath, + '-i', + 'bind', + '-f', + './test/fixtures/bind/example.com', + '-o', + 'example.com', + '-e', + 'bind', + ] + const { stdout, stderr } = await execFile('node', args) + assert.ok(stdout.includes('SOA')) + assert.strictEqual(stderr, '') + }) + + it('exports tinydns data as JSON (-e json)', async function () { + const binPath = path.resolve('bin', 'dns-zone.js') + const args = [binPath, '-i', 'tinydns', '-f', './test/fixtures/tinydns/data', '-e', 'json'] + const { stdout, stderr } = await execFile('node', args) + assert.ok(stdout.length > 0) + assert.strictEqual(stderr, '') + }) + + it('exports BIND zone as tinydns format (-e tinydns)', async function () { + const binPath = path.resolve('bin', 'dns-zone.js') + const args = [ + binPath, + '-i', + 'bind', + '-f', + './test/fixtures/bind/example.com', + '-o', + 'example.com', + '-e', + 'tinydns', + ] + const { stdout, stderr } = await execFile('node', args) + assert.ok(stdout.includes('example.com')) + assert.strictEqual(stderr, '') + }) + + it('exports BIND zone as maradns format (-e maradns)', async function () { + const binPath = path.resolve('bin', 'dns-zone.js') + const args = [ + binPath, + '-i', + 'bind', + '-f', + './test/fixtures/bind/example.com', + '-o', + 'example.com', + '-e', + 'maradns', + ] + const { stdout, stderr } = await execFile('node', args) + assert.ok(stdout.includes('/ttl') || stdout.includes('/origin') || stdout.includes('example.com')) + assert.strictEqual(stderr, '') + }) + + it('prints usage to stderr when -f flag is missing', async function () { + const binPath = path.resolve('bin', 'dns-zone.js') + const args = [binPath, '-i', 'bind'] + try { + await execFile('node', args) + assert.fail('should have exited with non-zero code') + } catch (e) { + assert.ok(e.code !== 0) + assert.ok(e.stderr.length > 0) + } + }) }) diff --git a/test/fixtures/bind/mutual-a b/test/fixtures/bind/mutual-a new file mode 100644 index 0000000..ad64603 --- /dev/null +++ b/test/fixtures/bind/mutual-a @@ -0,0 +1 @@ +$INCLUDE mutual-a diff --git a/test/fixtures/bind/mutual-b b/test/fixtures/bind/mutual-b new file mode 100644 index 0000000..2f9b6b5 --- /dev/null +++ b/test/fixtures/bind/mutual-b @@ -0,0 +1 @@ +$INCLUDE mutual-b diff --git a/test/fixtures/bind/self-include b/test/fixtures/bind/self-include new file mode 100644 index 0000000..2adf3ed --- /dev/null +++ b/test/fixtures/bind/self-include @@ -0,0 +1 @@ +$INCLUDE self-include diff --git a/test/fixtures/json/example.com b/test/fixtures/json/example.com new file mode 100644 index 0000000..52d50f0 --- /dev/null +++ b/test/fixtures/json/example.com @@ -0,0 +1,5 @@ +{"owner":"example.com.","type":"SOA","ttl":3600,"class":"IN","mname":"ns.example.com.","rname":"admin.example.com.","serial":2020091025,"refresh":7200,"retry":3600,"expire":604800,"minimum":3600} +{"owner":"example.com.","type":"NS","ttl":3600,"class":"IN","dname":"ns.example.com."} +{"owner":"example.com.","type":"A","ttl":3600,"class":"IN","address":"1.2.3.4"} +{"owner":"example.com.","type":"MX","ttl":3600,"class":"IN","preference":10,"exchange":"mail.example.com."} +{"owner":"ns.example.com.","type":"AAAA","ttl":3600,"class":"IN","address":"2001:0db8:0000:0000:0000:0000:0000:0001"} diff --git a/test/index.js b/test/index.js index 2061864..74a4e8b 100644 --- a/test/index.js +++ b/test/index.js @@ -1,5 +1,6 @@ import assert from 'assert' import os from 'os' +import { describe, it } from 'node:test' import * as dz from '../index.js' @@ -59,6 +60,40 @@ describe('dns-zone', function () { }) }) + describe('valueCleanup', function () { + it('strips double quotes from a quoted string', function () { + assert.equal(dz.valueCleanup('"quoted value"'), 'quoted value') + }) + + it('returns numeric strings as numbers', function () { + assert.strictEqual(dz.valueCleanup('3600'), 3600) + }) + + it('returns unquoted non-numeric strings as-is', function () { + assert.equal(dz.valueCleanup('example.com.'), 'example.com.') + }) + }) + + describe('serialByDate', function () { + it('returns a 10-digit date-based serial', function () { + const serial = dz.serialByDate() + assert.ok(Number.isInteger(serial)) + assert.ok(serial > 2020010100) + assert.match(serial.toString(), /^\d{10}$/) + }) + + it('uses provided increment', function () { + const s1 = dz.serialByDate(0) + const s5 = dz.serialByDate(5) + assert.equal(s5 - s1, 5) + }) + + it('pads single-digit increment', function () { + const serial = dz.serialByDate(3) + assert.equal(serial.toString().slice(-2), '03') + }) + }) + describe('toSeconds', function () { const cases = { '1w2d3h4m5s': 788645, diff --git a/test/json.js b/test/json.js new file mode 100644 index 0000000..74aa143 --- /dev/null +++ b/test/json.js @@ -0,0 +1,61 @@ +import assert from 'assert' +import fs from 'fs/promises' +import { describe, it } from 'node:test' + +import * as RR from '@nictool/dns-resource-record' +import * as json from '../lib/json.js' + +describe('json', function () { + describe('parseZoneFile', function () { + it('parses empty string', async () => { + const r = await json.parseZoneFile('') + assert.deepStrictEqual(r, []) + }) + + it('parses a single A record', async () => { + const r = await json.parseZoneFile( + '{"owner":"example.com.","type":"A","ttl":3600,"class":"IN","address":"1.2.3.4"}', + ) + assert.equal(r.length, 1) + assert.deepStrictEqual( + r[0], + new RR.A({ owner: 'example.com.', type: 'A', ttl: 3600, class: 'IN', address: '1.2.3.4' }), + ) + }) + + it('parses example.com fixture', async () => { + const buf = await fs.readFile('./test/fixtures/json/example.com') + const rrs = await json.parseZoneFile(buf.toString()) + assert.equal(rrs.length, 5) + assert.equal(rrs[0].get('type'), 'SOA') + assert.equal(rrs[2].get('type'), 'A') + assert.equal(rrs[2].get('address'), '1.2.3.4') + }) + + it('round-trips BIND → JSON → RR', async () => { + const bind = (await import('../lib/bind.js')).default + const buf = await fs.readFile('./test/fixtures/bind/example.net') + const rrs = await bind.parseZoneFile(buf.toString(), { file: './test/fixtures/bind/example.net' }) + const ndjson = rrs + .filter((r) => r.get) + .map((r) => JSON.stringify(Object.fromEntries(r))) + .join('\n') + const roundTripped = await json.parseZoneFile(ndjson) + assert.equal(roundTripped.length, rrs.filter((r) => r.get).length) + }) + + it('throws on record missing type', async () => { + await assert.rejects( + json.parseZoneFile('{"owner":"example.com.","ttl":3600,"address":"1.2.3.4"}'), + /missing 'type'/, + ) + }) + + it('throws on unknown RR type', async () => { + await assert.rejects( + json.parseZoneFile('{"owner":"example.com.","type":"BOGUS","ttl":3600}'), + /unknown RR type/, + ) + }) + }) +}) diff --git a/test/maradns.js b/test/maradns.js index 3458f97..ce4759f 100644 --- a/test/maradns.js +++ b/test/maradns.js @@ -1,6 +1,7 @@ import assert from 'assert' import fs from 'fs/promises' import os from 'os' +import { describe, it, beforeEach } from 'node:test' import * as RR from '@nictool/dns-resource-record' import mara from '../lib/maradns.js' @@ -112,7 +113,7 @@ describe('maradns', function () { ) }) - it.skip(`parses CAA record (RAW)`, async () => { + it(`parses CAA record (RAW)`, async () => { const r = await mara.parseZoneFile(`example.com. RAW 257 \x00\x05'issueletsencrypt.org' ~\n`) assert.deepStrictEqual( r[0], @@ -338,6 +339,34 @@ describe('maradns', function () { }) }) + it('parses /ttl directive', async () => { + const r = await mara.parseZoneFile('/ttl 3600 ~') + assert.deepStrictEqual(r, []) + assert.equal(mara.zoneOpts.ttl, 3600) + }) + + it('parses /origin directive', async () => { + const r = await mara.parseZoneFile('/origin example.net ~') + assert.deepStrictEqual(r, []) + assert.equal(mara.zoneOpts.origin, 'example.net.') + }) + + it('parses PTR without trailing dot (appends .in-addr.arpa.)', async () => { + mara.zoneOpts.ttl = 86400 + const r = await mara.parseZoneFile(`15.12.11.10 +64000 PTR c.example.net. ~\n`) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'PTR') + assert.equal(r[0].get('owner'), '15.12.11.10.in-addr.arpa.') + }) + + it('parses PTR without trailing dot with hex chars (appends .ip6.arpa.)', async () => { + mara.zoneOpts.ttl = 86400 + const r = await mara.parseZoneFile(`f.e.d.c.b.a +86400 PTR a.example.net. ~\n`) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'PTR') + assert.equal(r[0].get('owner'), 'f.e.d.c.b.a.ip6.arpa.') + }) + it('loads and validates example.com', async function () { const file = './test/fixtures/mara/example.net.csv2' const buf = await fs.readFile(file) diff --git a/test/tinydns.js b/test/tinydns.js new file mode 100644 index 0000000..cba4a39 --- /dev/null +++ b/test/tinydns.js @@ -0,0 +1,215 @@ +import assert from 'assert' +import fs from 'fs/promises' +import { describe, it, beforeEach } from 'node:test' + +import * as tinydns from '../lib/tinydns.js' + +beforeEach(() => { + for (const k in Object.keys(tinydns.zoneOpts)) delete tinydns.zoneOpts[k] +}) + +describe('tinydns', function () { + describe('parseData', function () { + it('parses empty string', async () => { + assert.deepStrictEqual(await tinydns.parseData(''), []) + }) + + it('ignores comment lines', async () => { + assert.deepStrictEqual(await tinydns.parseData('#this is a comment'), []) + }) + + it('ignores % location lines', async () => { + assert.deepStrictEqual(await tinydns.parseData('%lo:127.0.0.0/8'), []) + }) + + it('ignores - disabled lines', async () => { + assert.deepStrictEqual(await tinydns.parseData('-example.com.:192.168.1.1:86400'), []) + }) + + it('parses . (dot) record: SOA, NS, A', async () => { + const r = await tinydns.parseData('.example.com.:192.168.1.1:ns1:86400::') + assert.equal(r.length, 3) + assert.equal(r[0].get('type'), 'SOA') + assert.equal(r[1].get('type'), 'NS') + assert.equal(r[2].get('type'), 'A') + assert.equal(r[2].get('address'), '192.168.1.1') + }) + + it('parses . (dot) record without IP: SOA, NS only', async () => { + const r = await tinydns.parseData('.example.com.::ns1.example.com.:86400::') + assert.equal(r.length, 2) + assert.equal(r[0].get('type'), 'SOA') + assert.equal(r[1].get('type'), 'NS') + }) + + it('parses & (ampersand) record with IP: NS, A', async () => { + const r = await tinydns.parseData('&example.com.:192.168.1.1:ns1.example.com.:86400::') + assert.equal(r.length, 2) + assert.equal(r[0].get('type'), 'NS') + assert.equal(r[1].get('type'), 'A') + assert.equal(r[1].get('address'), '192.168.1.1') + }) + + it('parses = (equals) record: A, PTR', async () => { + const r = await tinydns.parseData('=example.com.:192.168.1.1:86400::') + assert.equal(r.length, 2) + assert.equal(r[0].get('type'), 'A') + assert.equal(r[1].get('type'), 'PTR') + assert.equal(r[1].get('owner'), '1.1.168.192.in-addr.arpa.') + }) + + it('parses @ (at) MX record with IP: MX, A', async () => { + const r = await tinydns.parseData('@example.com.:192.168.1.1:mail.example.com.:10:86400::') + assert.equal(r.length, 2) + assert.equal(r[0].get('type'), 'MX') + assert.equal(r[1].get('type'), 'A') + assert.equal(r[1].get('address'), '192.168.1.1') + }) + + it('parses ^ (caret) PTR record', async () => { + const r = await tinydns.parseData('^1.1.168.192.in-addr.arpa.:example.com.:86400::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'PTR') + }) + + it('parses 3 AAAA record', async () => { + const r = await tinydns.parseData('3a.example.net.:fd4d617261444e530000000100020003:86400::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'AAAA') + }) + + it('parses 6 AAAA+PTR record', async () => { + const r = await tinydns.parseData('6a.example.net.:fd4d617261444e530000000100020003:x:86400::') + assert.equal(r.length, 2) + assert.equal(r[0].get('type'), 'AAAA') + assert.equal(r[1].get('type'), 'PTR') + }) + + it('parses S SRV record', async () => { + const r = await tinydns.parseData('S_imaps._tcp.example.com.:mail.example.com.:993:1:0:86400::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'SRV') + assert.equal(r[0].get('port'), 993) + }) + + it('parses : generic AAAA (type 28) record', async () => { + const r = await tinydns.parseData( + ':a.example.net:28:\\375\\115\\141\\162\\141\\104\\116\\123\\000\\001\\000\\002\\000\\003\\000\\004:86400::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'AAAA') + }) + + it('parses : generic HINFO (type 13) record', async () => { + const r = await tinydns.parseData(':example.com:13:\\003x86\\005Linux:86400::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'HINFO') + assert.equal(r[0].get('cpu'), 'x86') + assert.equal(r[0].get('os'), 'Linux') + }) + + it('parses : generic SPF (type 99) record', async () => { + const r = await tinydns.parseData(':example.net:99:v=spf1 +mx -all:86400::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'SPF') + }) + + it('parses : generic SSHFP (type 44) record', async () => { + const r = await tinydns.parseData( + ':mail.example.com:44:\\001\\001\\035ed8c6e16fdae4f633eee6a7b8f64fdd356bbb32841d53:86400::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'SSHFP') + }) + + it('parses : generic LOC (type 29) record', async () => { + const r = await tinydns.parseData( + ':example.com:29:\\000\\045\\000\\000\\211\\026\\313\\074\\160\\303\\020\\337\\000\\230\\205\\120:3600::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'LOC') + }) + + it('parses : generic SRV (type 33) record', async () => { + const r = await tinydns.parseData( + ':_http._tcp.example.net:33:\\000\\000\\000\\000\\000\\120\\001a\\007example\\003net\\000:86400::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'SRV') + }) + + it('parses : generic NAPTR (type 35) record', async () => { + const r = await tinydns.parseData( + ':www.example.com:35:\\000\\144\\000\\144\\001S\\010http+I2R\\000\\027_http._tcp.example.com.\\000:86400::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'NAPTR') + }) + + it('parses : generic DNAME (type 39) record', async () => { + const r = await tinydns.parseData(':_tcp.example.com:39:\\004\\137tcp\\007example\\003net\\000:3600::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'DNAME') + }) + + it('parses : generic DS (type 43) record', async () => { + const r = await tinydns.parseData( + ':dskey.example.com:43:\\354\\105\\005\\0012BB183AF5F22588179A53B0A98631FAD1A292118:3600::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'DS') + }) + + it('parses : generic DNSKEY (type 48) record', async () => { + const r = await tinydns.parseData( + ':example.com:48:\\001\\000\\003\\005AQPSKmynfzW4kyBv015MUG2DeIQ3Cbl+BBZH4b\\0570PY1kxkmvHjcZc8nokfzj31GajIQKY+5CptLr3buXA10hWqTkF7H6RfoRqXQeogmMHfpftf6z:3600::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'DNSKEY') + }) + + it('parses : generic TLSA (type 52) record', async () => { + const r = await tinydns.parseData( + ':_443._tcp.example.com:52:\\000\\000\\001d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971:3600::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'TLSA') + }) + + it('parses : generic SMIMEA (type 53) record', async () => { + const r = await tinydns.parseData( + ':_443._tcp.www.example.com:53:\\000\\000\\001d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971:3600::', + ) + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'SMIMEA') + }) + + it('parses : generic URI (type 256) record', async () => { + const r = await tinydns.parseData(':www.example.com:256:\\000\\001\\000\\000www2.example.com.:3600::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'URI') + }) + + it('parses : generic CAA (type 257) record', async () => { + const r = await tinydns.parseData(':example.com:257:\\000\\005issue"letsencrypt.org":3600::') + assert.equal(r.length, 1) + assert.equal(r[0].get('type'), 'CAA') + }) + + it('throws on unsupported generic record type', async () => { + await assert.rejects(() => tinydns.parseData(':example.com:1000:data:86400::'), { + message: /unsupported tinydns generic record/, + }) + }) + + it('throws on unknown record type', async () => { + await assert.rejects(() => tinydns.parseData('Xgarbage'), { message: /garbage found in tinydns data/ }) + }) + + it('parses theartfarm.com data file', async () => { + const buf = await fs.readFile('./test/fixtures/tinydns/data') + const r = await tinydns.parseData(buf.toString()) + assert.ok(r.length > 0) + }) + }) +}) diff --git a/test/zone.js b/test/zone.js index 01c6880..8a1ff6d 100644 --- a/test/zone.js +++ b/test/zone.js @@ -1,4 +1,5 @@ import assert from 'assert' +import { describe, it, before } from 'node:test' import ZONE from '../lib/zone.js' import * as RR from '@nictool/dns-resource-record' @@ -24,19 +25,20 @@ describe('zone', function () { }) describe('setSOA', function () { + let zone before(function () { - this.zone = new ZONE({ origin: 'example.com' }) + zone = new ZONE({ origin: 'example.com' }) }) it('sets the zones SOA', function () { - this.zone.setSOA(testSOA) - assert.equal(this.zone.SOA.owner, 'example.com.') + zone.setSOA(testSOA) + assert.equal(zone.SOA.owner, 'example.com.') }) it('rejects a second SOA', function () { assert.throws( () => { - this.zone.setSOA(testSOA) + zone.setSOA(testSOA) }, { message: 'Exactly one SOA RR should be present at the top!, RFC 1035', @@ -46,9 +48,10 @@ describe('zone', function () { }) describe('addRR', function () { + let zone before(function () { - this.zone = new ZONE({ origin: 'example.com' }) - this.zone.setSOA(testSOA) + zone = new ZONE({ origin: 'example.com' }) + zone.setSOA(testSOA) }) const ns1 = new RR.NS({ @@ -60,9 +63,9 @@ describe('zone', function () { }) it('adds ns1 to a zone', function () { - this.zone.addRR(ns1) + zone.addRR(ns1) - const matches = this.zone.getRR(ns1) + const matches = zone.getRR(ns1) assert.equal(matches.length, 1) assert.deepEqual(matches[0], ns1) }) @@ -75,9 +78,9 @@ describe('zone', function () { type: 'NS', dname: 'ns2.example.com.', }) - this.zone.addRR(ns2) + zone.addRR(ns2) - const matches = this.zone.getRR(ns2) + const matches = zone.getRR(ns2) assert.equal(matches.length, 1) assert.deepEqual(matches[0], ns2) }) @@ -85,7 +88,7 @@ describe('zone', function () { it('rejects identical ns1', function () { assert.throws( () => { - this.zone.addRR(ns1) + zone.addRR(ns1) }, { message: 'multiple identical RRs are not allowed, RFC 2181', @@ -96,7 +99,7 @@ describe('zone', function () { it('rejects matching RRset with different TTL', function () { assert.throws( () => { - this.zone.addRR( + zone.addRR( new RR.NS({ owner: 'example.com.', ttl: 7200, @@ -121,9 +124,9 @@ describe('zone', function () { }) it('adds A record to a zone', function () { - this.zone.addRR(a1) + zone.addRR(a1) - const matches = this.zone.getRR(a1) + const matches = zone.getRR(a1) assert.equal(matches.length, 1) assert.deepEqual(matches[0], a1) }) @@ -136,9 +139,9 @@ describe('zone', function () { type: 'A', address: '192.0.2.128', }) - this.zone.addRR(a2) + zone.addRR(a2) - const matches = this.zone.getRR(a2) + const matches = zone.getRR(a2) assert.equal(matches.length, 1) assert.deepEqual(matches[0], a2) }) @@ -146,7 +149,7 @@ describe('zone', function () { it('rejects identical a1', function () { assert.throws( () => { - this.zone.addRR(a1) + zone.addRR(a1) }, { message: 'multiple identical RRs are not allowed, RFC 2181', @@ -163,8 +166,8 @@ describe('zone', function () { }) it('adds cname1 to a zone', function () { - this.zone.addRR(cn1) - const matches = this.zone.getRR(cn1) + zone.addRR(cn1) + const matches = zone.getRR(cn1) assert.equal(matches.length, 1) assert.deepEqual(matches[0], cn1) }) @@ -172,7 +175,7 @@ describe('zone', function () { it('fails to add CNAME with matching owner', function () { assert.throws( () => { - this.zone.addRR( + zone.addRR( new RR.CNAME({ owner: 'www2.example.com.', ttl: 3600, @@ -191,7 +194,7 @@ describe('zone', function () { it('fails to add CNAME with matching owner and incompatible type', function () { assert.throws( () => { - this.zone.addRR( + zone.addRR( new RR.CNAME({ owner: 'example.com.', ttl: 3600, @@ -210,7 +213,7 @@ describe('zone', function () { it('fails to add AAAA adjacent to CNAME', function () { assert.throws( () => { - this.zone.addRR( + zone.addRR( new RR.AAAA({ owner: 'www2.example.com.', ttl: 3600,