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,