sdxc

[ Operations ]

@sdxc/zone-file

Read and write RFC 1035 DNS zone files, with the record data codec for every typed record

npm add @sdxc/zone-file
pnpm add @sdxc/zone-file
yarn add @sdxc/zone-file
bun add @sdxc/zone-file
Installs with
@sdxc/result
Used by
uptime

Read and write RFC 1035 DNS zone files, with the record data codec for every typed record.

A zone file (a BIND "master file") is how DNS operators export, import, review and version a zone. parse reads the whole grammar and returns the records it read beside every entry it could not use; stringify writes records back, so parse(stringify(zone)) gives the same records.

Installation

npm add @sdxc/zone-file

parse and parseRecordData return a Result from @sdxc/result, which installs alongside this package.

Usage

Read a zone file

import { formatRecordData, parse } from "@sdxc/zone-file";
import { isFailure } from "@sdxc/result";

let text = `$TTL 1h
@    IN SOA ns1 hostmaster ( 1 2h 1h 1w 5m )
     IN NS  ns1 ; primary
www  IN A   192.0.2.1
`;

let parsed = parse(text, { origin: "example.com" });
if (isFailure(parsed)) throw parsed.error; // only for input over `maxBytes`

for (let record of parsed.data.records) {
	console.log(record.name, record.ttl, record.type, formatRecordData(record));
}
// example.com 3600 SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 604800 300
// example.com 3600 NS ns1.example.com.
// www.example.com 3600 A 192.0.2.1

Every record comes back with an absolute, lowercased name, its ttl, class, type, the typed fields of its type (address, host, serial…) and where it was written (file, line, endLine, comment).

Check what it could not read

An entry that does not parse becomes a rejection, and the rest of the file still reads.

import { parse } from "@sdxc/zone-file";
import { unwrap } from "@sdxc/result";

let zone = unwrap(parse("bad IN A 999.1.1.1\nok IN A 192.0.2.1\n", { origin: "example.com" }));

zone.records.length; // 1
zone.rejected;
// [{ file: null, line: 1, endLine: 1, input: "bad IN A 999.1.1.1",
//    reason: "invalid-data", message: 'Invalid A record data: "999.1.1.1"' }]

Write a zone file

import { stringify } from "@sdxc/zone-file";

let text = 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 and print record data on its own

import { formatRecordData, parseRecordData } from "@sdxc/zone-file";

parseRecordData("MX", "10 Mail.Example.com.");
// success({ type: "MX", preference: 10, exchange: "mail.example.com" })

formatRecordData({ type: "MX", preference: 10, exchange: "mail.example.com" });
// "10 mail.example.com."

API

parse(text: string, options: ParseOptions): Result<Zone, ZoneFileError>

Reads a zone file into { origin, records, rejected }. It fails only with a ZoneFileError for input over maxBytes; every other problem is a Rejection beside the records that did parse, and a caller that wants all-or-nothing checks rejected.length. Every record is returned — outside the origin, repeated, any class, any type — and the caller decides which to keep.

OptionDefaultMeaning
originRequiredThe initial $ORIGIN; @ and relative names resolve against it
ttlnullThe TTL a record gets when neither it, a $TTL nor an earlier record states one
includeNone(fileName, origin) => string | null returns an $INCLUDEd file's text; 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
maxBytes1 MiBThe largest input in UTF-8 bytes, counted across included files

The grammar it reads:

  • ; starts a comment outside quotes; a record's trailing comment is kept as comment.

  • "…" quotes a field, with \", \\ and \DDD escapes.

  • ( … ) joins lines into one entry, from line to endLine.

  • @ is the current origin, and a name without a trailing dot gets the origin appended, in owners and in the name fields of typed data. Names come back absolute, lowercased, without the trailing dot; 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, else options.ttl.

  • $ORIGIN, $TTL and $INCLUDE file [origin] are read. An included file starts at the given or current origin, the origin, $TTL and owner revert after it, and includes nest 8 deep.

  • Classes are IN, CH, HS, CS and CLASSnnn; types are any mnemonic or TYPEnnn, and RFC 3597 generic data (\# 4 C0000201) is read for every type.

stringify(zone: ZoneInput, options?: StringifyOptions): string

Writes { origin?, ttl?, records } (a parsed Zone is one) as one tab-separated line per record, ending in a newline. $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. Comments are written back after ;; spacing, standalone comments and parentheses are not.

parseRecordData<Type extends RecordType>(type: Type, data: string): Result<RecordData<Type>, RecordDataError>

Reads RDATA in presentation format, or RFC 3597 generic form, into the typed fields of A, AAAA, CNAME, NS, PTR, DNAME, MX, TXT, CAA, SRV and SOA (SOA timers accept TTL units). Any other type returns { type, data } as written. Names come back lowercased without the trailing dot, and a relative name stays relative.

parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
// success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })

formatRecordData(data: RecordData<RecordType>): 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 non-printable octets as \DDD, untyped data as is. Two spellings of one record, such as 0 ISSUE "ca.example" and its generic form, print to one string.

canonicalType(type: string): string

The uppercase mnemonic for a type, resolving TYPEnnn when it has one: canonicalType("type16") is "TXT".

typeName(code: number): string

The mnemonic for a type code, or TYPEnnn without one: typeName(16) is "TXT".

ZoneFileError

The failure parse returns when the input, with its includes, passes maxBytes. code is "too-large" and bytes holds the size read.

RecordDataError

The failure parseRecordData returns for data that does not fit its type.

Types

Every type is exported top-level and under the ZoneFile namespace (ZoneFile.Zone).

  • Zone: { origin, records, rejected }, where origin is the origin at the end of the file.

  • Record: any record read from a file; RecordFor<Type> narrows it to one type, and RecordFields is what every record carries beside its data (name, ttl, class, file, line, endLine, comment).

  • UntypedRecord: a record of a type without typed fields, with its data as written and the origin its relative names need.

  • Rejection: { file, line, endLine, input, reason, message } for an entry that did not become a record. RejectionReason is "malformed", "invalid-data", "missing-owner", "include" (no include option, a file it returned null for, or nesting past 8) or "unsupported-directive" ($GENERATE or any other $ word).

  • RecordData<Type>: the typed fields of a type: AData, AAAAData, CNAMEData, NSData, PTRData, DNAMEData, MXData, TXTData, CAAData, SRVData, SOAData, or UnknownData.

  • RecordType, TypedRecordType, RecordClass: type and class names.

  • ParseOptions, StringifyOptions: the options above.

  • ZoneInput, RecordInput, RecordInputFields: what stringify accepts; a record needs only name, its type and fields, with optional ttl, class (default "IN") and comment.

Pattern: Import Only The Records You Track

parse returns everything, so an import policy is a filter. Exports from DNS providers often drop the trailing dot on fully-qualified names; relativeNames: "origin-suffix" reads those as absolute.

import { parse } from "@sdxc/zone-file";
import { isFailure } from "@sdxc/result";

function importZone(text: string, domain: string) {
	let parsed = parse(text, { origin: domain, relativeNames: "origin-suffix" });
	if (isFailure(parsed)) return { records: [], errors: [parsed.error.message] };

	let records = parsed.data.records.filter(
		(record) =>
			record.class === "IN" &&
			["A", "AAAA", "MX", "TXT"].includes(record.type) &&
			(record.name === domain || record.name.endsWith(`.${domain}`)),
	);
	let errors = parsed.data.rejected.map((entry) => `Line ${entry.line}: ${entry.message}`);
	return { records, errors };
}

Pattern: Normalize A Zone File For Review

Reading and printing gives every zone one spelling — lowercased names, canonical addresses, quoted TXT — so two exports diff only where their records differ.

import { parse, stringify } from "@sdxc/zone-file";
import { unwrap } from "@sdxc/result";

function normalize(text: string, origin: string): string {
	let zone = unwrap(parse(text, { origin }));
	let records = [...zone.records].sort(
		(a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type),
	);
	return stringify({ origin, records }, { relative: true });
}

Pattern: Refuse A File With Any Bad Line

For an all-or-nothing import, treat any rejection as a failure and report each with its line.

import type { Result } from "@sdxc/result";
import type { Zone } from "@sdxc/zone-file";

import { parse } from "@sdxc/zone-file";
import { failure, isFailure, success } from "@sdxc/result";

function parseStrict(text: string, origin: string): Result<Zone, Error> {
	let parsed = parse(text, { origin, maxBytes: 256 * 1024 });
	if (isFailure(parsed)) return parsed;

	let { rejected } = parsed.data;
	if (rejected.length === 0) return success(parsed.data);

	let lines = rejected.map((entry) => `Line ${entry.line} (${entry.reason}): ${entry.input}`);
	return failure(new Error(lines.join("\n")));
}

Pattern: Read A Zone With Includes

The include callback supplies each $INCLUDEd file; records from it carry its name as file.

import { readFileSync } from "node:fs";
import { join } from "node:path";

import { parse } from "@sdxc/zone-file";

let parsed = parse(readFileSync("zones/example.com.zone", "utf8"), {
	origin: "example.com",
	ttl: 3600,
	include: (fileName) => readFileSync(join("zones", fileName), "utf8"),
});

Pattern: Compare A Zone File With Live DNS

A resolver answers with a type code and RDATA that may be in generic form. Reading both sides with parseRecordData and printing with formatRecordData gives one key per record, whichever way it was spelled.

import { formatRecordData, parse, parseRecordData, typeName } from "@sdxc/zone-file";
import { isSuccess, unwrap } from "@sdxc/result";

interface Answer {
	name: string;
	type: number;
	data: string;
}

async function missingFromDns(text: string, origin: string, name: string, type: string) {
	let url = new URL("https://cloudflare-dns.com/dns-query");
	url.searchParams.set("name", name);
	url.searchParams.set("type", type);
	let response = await fetch(url, { headers: { Accept: "application/dns-json" } });
	let body = (await response.json()) as { Answer?: Answer[] };

	let live = new Set<string>();
	for (let answer of body.Answer ?? []) {
		let data = parseRecordData(typeName(answer.type), answer.data);
		if (isSuccess(data)) live.add(formatRecordData(data.data));
	}

	return unwrap(parse(text, { origin }))
		.records.filter((record) => record.name === name && record.type === type)
		.filter((record) => !live.has(formatRecordData(record)));
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.