[ Operations ]
@sdxc/zone-file
Read and write RFC 1035 DNS zone files, with the record data codec for every typed record
- Installs with
- @sdxc/result
- Used by
- uptime
- Source
- packages/zone-file
Read and write RFC 1035 DNS zone files, with the record data codec for every typed record.
Installation
npm add @sdxc/zone-file
parse returns a @sdxc/result value, which
installs alongside this package.
A zone file (a BIND "master file") is how DNS operators export, import, review and version a
zone. parse reads the whole RFC 1035 grammar — $ORIGIN, $TTL, $INCLUDE, parentheses,
blank owners, escapes, TTL units, any class and any type — and returns the records it read beside
every entry it could not use, each with its line and a reason. stringify writes records back, so
parse(stringify(zone)) gives the same records.
Usage
Read A Zone File
import * as ZoneFile from "@sdxc/zone-file";
import { isSuccess } from "@sdxc/result";
let parsed = ZoneFile.parse(text, { origin: "example.com" });
if (isSuccess(parsed)) {
for (let record of parsed.data.records) console.log(record.name, record.ttl, record.type);
for (let rejection of parsed.data.rejected) console.warn(rejection.line, rejection.message);
}
Write One
import * as ZoneFile from "@sdxc/zone-file";
let text = ZoneFile.stringify(
{
origin: "example.com",
ttl: 3600,
records: [
{ name: "example.com", type: "MX", preference: 10, exchange: "mail.example.com" },
{ name: "www.example.com", ttl: 300, type: "CNAME", target: "example.com" },
],
},
{ relative: true },
);
// $ORIGIN example.com.
// $TTL 3600
// @ IN MX 10 mail
// www 300 IN CNAME @
Read Record Data From Anywhere
import { parseRecordData } from "@sdxc/zone-file";
parseRecordData("MX", "10 Mail.Example.com.");
// success({ type: "MX", preference: 10, exchange: "mail.example.com" })
API
parse(text, options): Result<ZoneFile.Zone, ZoneFileError>
Reads a zone file into { origin, records, rejected }. The only failure is a ZoneFileError
for input past maxBytes; any other problem — a bad line, an unknown directive, RDATA that does
not fit its type — is a Rejection beside the records that did parse, so a caller wanting
all-or-nothing checks rejected.length.
| Option | Default | Meaning |
|---|---|---|
origin | Required | The initial $ORIGIN; @ and relative names resolve against it until a $ORIGIN line changes it |
ttl | null | The TTL a record gets when neither it, a $TTL nor an earlier record states one |
include | None | (fileName, origin) => string | null, the text of an $INCLUDEd file; without it every $INCLUDE is rejected |
relativeNames | "rfc1035" | "origin-suffix" also reads a dotless name that equals the origin or ends in it as absolute |
maxBytes | 1 MiB | The largest input in UTF-8 bytes, counted across included files |
The grammar:
;starts a comment outside quotes; a record's trailing comment is kept ascomment."…"quotes a field, with\",\\and\DDDescapes; a;or parenthesis inside is data.(…)joins lines into one entry;lineis where it starts andendLinewhere it ends.@is the current origin, and a name without a trailing dot gets the origin appended, in owners and in the name fields of typed RDATA.Names come back absolute, lowercased, without the trailing dot,
\DDDand\.escapes resolved to one canonical spelling; the root is".".A line starting with whitespace takes the previous record's owner.
TTL and class are optional, in either order. A TTL is seconds or BIND units (
1h30m,2W), up to 2³¹−1. A record without one takes$TTL, else the previous record's TTL, elseoptions.ttl.$ORIGIN,$TTLand$INCLUDE file [origin]are read. An included file starts with the given or current origin, and the origin,$TTLand owner revert after it; includes nest 8 deep.$GENERATEis rejected.Classes are
IN,CH,HS,CSandCLASSnnn; types are any mnemonic orTYPEnnn, and RFC 3597 generic data (\# 4 C0000201) is read for every type.
Records outside the origin, a record listed twice, classes other than IN and every type are all
returned: which of them to keep is the caller's decision.
stringify(zone, options?): string
Writes { origin?, ttl?, records } (a parsed Zone is one) as one tab-separated line per record.
$ORIGIN and $TTL lead when the input names them, and a record's TTL is left out when it equals
$TTL. relative: true writes names under the origin relative to it and the apex as @;
otherwise every name is absolute. An untyped record read under another origin gets an $ORIGIN
line first, so relative names in its verbatim data keep their meaning. Comments are written back
after ;; spacing, standalone comments and parentheses are not kept.
parseRecordData(type, data): Result<ZoneFile.RecordData<Type>, RecordDataError>
Reads RDATA in presentation format into typed fields. Typed types: A and AAAA (address, IPv6
in RFC 5952 form), CNAME, PTR and DNAME (target), NS (host), MX (preference,
exchange), TXT (text, strings), CAA (flags, critical, tag, value), SRV
(priority, weight, port, target) and SOA (primary, mailbox, serial, refresh,
retry, expire, minimum, the timers accepting TTL units). Any other type returns
{ type, data }. RFC 3597 generic data is decoded for the typed types. Names are not qualified:
relative names stay relative.
parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
// success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })
formatRecordData(data): string
Prints record data in canonical presentation format, the inverse of parseRecordData: names
absolute with the trailing dot, TXT strings and the CAA value quoted with every octet outside
printable ASCII as \DDD, the CAA tag lowercased, untyped data as is. Two spellings of one record —
0 ISSUE "ca.example" and its generic form — print to one string.
formatRecordData({ type: "MX", preference: 10, exchange: "mx.example.com" }); // "10 mx.example.com."
canonicalType(type): string / typeName(code): string
canonicalType("txt") is "TXT" and canonicalType("TYPE16") is "TXT"; typeName(16) is
"TXT", and a code without a mnemonic is "TYPEnnn".
Errors
| Class | When |
|---|---|
ZoneFileError | code: "too-large": the input, with its includes, passed maxBytes; bytes holds it |
RecordDataError | parseRecordData on data that does not fit the type |
Rejection reasons
reason | When |
|---|---|
malformed | An unterminated quote, unbalanced parentheses, a bad owner or TTL, a missing type or data |
invalid-data | RDATA that does not fit its type; message holds the codec's error |
missing-owner | A blank owner with no earlier record to take it from |
include | $INCLUDE without the include option, a file it returned null for, or nesting past 8 |
unsupported-directive | $GENERATE or any other $ word |
Each rejection carries file (null for the text passed to parse), line, endLine, input
(the entry as written) and message.
ZoneFile namespace
Types only: Zone, Record, RecordFor<Type>, RecordFields, UntypedRecord, Rejection,
RejectionReason, ParseOptions, ZoneInput, RecordInput, StringifyOptions, RecordType,
TypedRecordType, RecordClass, RecordData<Type>, and one data interface per typed type
(AData through SOAData) plus UnknownData.
Pattern: Import Only What You Track
parse returns everything; the policy is a filter.
import * as ZoneFile from "@sdxc/zone-file";
import { isFailure } from "@sdxc/result";
let parsed = ZoneFile.parse(text, { origin: domain, relativeNames: "origin-suffix" });
if (isFailure(parsed)) throw new Error(parsed.error.message);
let tracked = parsed.data.records.filter(
(record) =>
record.class === "IN" &&
["A", "AAAA", "MX", "TXT"].includes(record.type) &&
(record.name === domain || record.name.endsWith(`.${domain}`)),
);
Pattern: Normalize A Zone File For Review
Reading and printing gives every zone one spelling — absolute names, canonical addresses, quoted TXT — so two exports diff only where their records differ.
import * as ZoneFile from "@sdxc/zone-file";
import { unwrap } from "@sdxc/result";
function normalize(text: string, origin: string): string {
let zone = unwrap(ZoneFile.parse(text, { origin }));
let records = [...zone.records].sort((a, b) => a.name.localeCompare(b.name));
return ZoneFile.stringify({ origin, records }, { relative: true });
}
Pattern: Read A Zone With Includes
import { readFileSync } from "node:fs";
import { join } from "node:path";
import * as ZoneFile from "@sdxc/zone-file";
let parsed = ZoneFile.parse(readFileSync("zones/example.com.zone", "utf8"), {
origin: "example.com",
include: (fileName) => readFileSync(join("zones", fileName), "utf8"),
});