@sdxc/zone-file 2026.10.10 → 2026.10.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -111
- package/dist/addresses.d.ts +17 -0
- package/dist/addresses.js +50 -0
- package/dist/dnssec.d.ts +18 -0
- package/dist/dnssec.js +53 -0
- package/dist/format-record-data.d.ts +3 -4
- package/dist/format-record-data.js +24 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/octets.d.ts +15 -0
- package/dist/octets.js +37 -0
- package/dist/parse-record-data.js +115 -16
- package/dist/parse.js +2 -0
- package/dist/record-types.d.ts +8 -0
- package/dist/record-types.js +23 -0
- package/dist/svc-params.d.ts +24 -0
- package/dist/svc-params.js +338 -0
- package/dist/types.d.ts +91 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -2,42 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
Read and write RFC 1035 DNS zone files, with the record data codec for every typed record.
|
|
4
4
|
|
|
5
|
+
A zone file (a BIND "master file") is how DNS operators export, import, review and version a
|
|
6
|
+
zone. `parse` reads the whole grammar and returns the records it read beside every entry it could
|
|
7
|
+
not use; `stringify` writes records back, so `parse(stringify(zone))` gives the same records.
|
|
8
|
+
|
|
5
9
|
## Installation
|
|
6
10
|
|
|
7
|
-
```
|
|
11
|
+
```sh
|
|
8
12
|
npm add @sdxc/zone-file
|
|
9
13
|
```
|
|
10
14
|
|
|
11
|
-
`parse`
|
|
12
|
-
installs alongside this
|
|
13
|
-
|
|
14
|
-
A zone file (a BIND "master file") is how DNS operators export, import, review and version a
|
|
15
|
-
zone. `parse` reads the whole RFC 1035 grammar — `$ORIGIN`, `$TTL`, `$INCLUDE`, parentheses,
|
|
16
|
-
blank owners, escapes, TTL units, any class and any type — and returns the records it read beside
|
|
17
|
-
every entry it could not use, each with its line and a reason. `stringify` writes records back, so
|
|
18
|
-
`parse(stringify(zone))` gives the same records.
|
|
15
|
+
`parse` and `parseRecordData` return a `Result` from
|
|
16
|
+
[`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which installs alongside this
|
|
17
|
+
package.
|
|
19
18
|
|
|
20
19
|
## Usage
|
|
21
20
|
|
|
22
|
-
### Read
|
|
21
|
+
### Read a zone file
|
|
23
22
|
|
|
24
23
|
```typescript
|
|
25
|
-
import
|
|
26
|
-
import {
|
|
24
|
+
import { formatRecordData, parse } from "@sdxc/zone-file";
|
|
25
|
+
import { isFailure } from "@sdxc/result";
|
|
26
|
+
|
|
27
|
+
let text = `$TTL 1h
|
|
28
|
+
@ IN SOA ns1 hostmaster ( 1 2h 1h 1w 5m )
|
|
29
|
+
IN NS ns1 ; primary
|
|
30
|
+
www IN A 192.0.2.1
|
|
31
|
+
`;
|
|
27
32
|
|
|
28
|
-
let parsed =
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
let parsed = parse(text, { origin: "example.com" });
|
|
34
|
+
if (isFailure(parsed)) throw parsed.error; // only for input over `maxBytes`
|
|
35
|
+
|
|
36
|
+
for (let record of parsed.data.records) {
|
|
37
|
+
console.log(record.name, record.ttl, record.type, formatRecordData(record));
|
|
32
38
|
}
|
|
39
|
+
// example.com 3600 SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 604800 300
|
|
40
|
+
// example.com 3600 NS ns1.example.com.
|
|
41
|
+
// www.example.com 3600 A 192.0.2.1
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Every record comes back with an absolute, lowercased `name`, its `ttl`, `class`, `type`, the
|
|
45
|
+
typed fields of its type (`address`, `host`, `serial`…) and where it was written (`file`, `line`,
|
|
46
|
+
`endLine`, `comment`).
|
|
47
|
+
|
|
48
|
+
### Check what it could not read
|
|
49
|
+
|
|
50
|
+
An entry that does not parse becomes a rejection, and the rest of the file still reads.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { parse } from "@sdxc/zone-file";
|
|
54
|
+
import { unwrap } from "@sdxc/result";
|
|
55
|
+
|
|
56
|
+
let zone = unwrap(parse("bad IN A 999.1.1.1\nok IN A 192.0.2.1\n", { origin: "example.com" }));
|
|
57
|
+
|
|
58
|
+
zone.records.length; // 1
|
|
59
|
+
zone.rejected;
|
|
60
|
+
// [{ file: null, line: 1, endLine: 1, input: "bad IN A 999.1.1.1",
|
|
61
|
+
// reason: "invalid-data", message: 'Invalid A record data: "999.1.1.1"' }]
|
|
33
62
|
```
|
|
34
63
|
|
|
35
|
-
### Write
|
|
64
|
+
### Write a zone file
|
|
36
65
|
|
|
37
66
|
```typescript
|
|
38
|
-
import
|
|
67
|
+
import { stringify } from "@sdxc/zone-file";
|
|
39
68
|
|
|
40
|
-
let text =
|
|
69
|
+
let text = stringify(
|
|
41
70
|
{
|
|
42
71
|
origin: "example.com",
|
|
43
72
|
ttl: 3600,
|
|
@@ -54,169 +83,243 @@ let text = ZoneFile.stringify(
|
|
|
54
83
|
// www 300 IN CNAME @
|
|
55
84
|
```
|
|
56
85
|
|
|
57
|
-
### Read
|
|
86
|
+
### Read and print record data on its own
|
|
58
87
|
|
|
59
88
|
```typescript
|
|
60
|
-
import { parseRecordData } from "@sdxc/zone-file";
|
|
89
|
+
import { formatRecordData, parseRecordData } from "@sdxc/zone-file";
|
|
61
90
|
|
|
62
91
|
parseRecordData("MX", "10 Mail.Example.com.");
|
|
63
92
|
// success({ type: "MX", preference: 10, exchange: "mail.example.com" })
|
|
93
|
+
|
|
94
|
+
formatRecordData({ type: "MX", preference: 10, exchange: "mail.example.com" });
|
|
95
|
+
// "10 mail.example.com."
|
|
64
96
|
```
|
|
65
97
|
|
|
66
98
|
## API
|
|
67
99
|
|
|
68
|
-
### `parse(text, options): Result<
|
|
100
|
+
### `parse(text: string, options: ParseOptions): Result<Zone, ZoneFileError>`
|
|
69
101
|
|
|
70
|
-
Reads a zone file into `{ origin, records, rejected }`.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
Reads a zone file into `{ origin, records, rejected }`. It fails only with a `ZoneFileError` for
|
|
103
|
+
input over `maxBytes`; every other problem is a `Rejection` beside the records that did parse,
|
|
104
|
+
and a caller that wants all-or-nothing checks `rejected.length`. Every record is returned —
|
|
105
|
+
outside the origin, repeated, any class, any type — and the caller decides which to keep.
|
|
74
106
|
|
|
75
|
-
| Option | Default | Meaning
|
|
76
|
-
| --------------- | ----------- |
|
|
77
|
-
| `origin` | Required | The initial `$ORIGIN`; `@` and relative names resolve against it
|
|
78
|
-
| `ttl` | `null` | The TTL a record gets when neither it, a `$TTL` nor an earlier record states one
|
|
79
|
-
| `include` | None | `(fileName, origin) => string \| null
|
|
80
|
-
| `relativeNames` | `"rfc1035"` | `"origin-suffix"` also reads a dotless name that equals the origin or ends in it as absolute
|
|
81
|
-
| `maxBytes` | 1 MiB | The largest input in UTF-8 bytes, counted across included files
|
|
107
|
+
| Option | Default | Meaning |
|
|
108
|
+
| --------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
109
|
+
| `origin` | Required | The initial `$ORIGIN`; `@` and relative names resolve against it |
|
|
110
|
+
| `ttl` | `null` | The TTL a record gets when neither it, a `$TTL` nor an earlier record states one |
|
|
111
|
+
| `include` | None | `(fileName, origin) => string \| null` returns an `$INCLUDE`d file's text; without it every `$INCLUDE` is rejected |
|
|
112
|
+
| `relativeNames` | `"rfc1035"` | `"origin-suffix"` also reads a dotless name that equals the origin or ends in it as absolute |
|
|
113
|
+
| `maxBytes` | 1 MiB | The largest input in UTF-8 bytes, counted across included files |
|
|
82
114
|
|
|
83
|
-
The grammar:
|
|
115
|
+
The grammar it reads:
|
|
84
116
|
|
|
85
117
|
- `;` starts a comment outside quotes; a record's trailing comment is kept as `comment`.
|
|
86
|
-
- `"…"` quotes a field, with `\"`, `\\` and `\DDD` escapes
|
|
87
|
-
- `(` … `)` joins lines into one entry
|
|
88
|
-
- `@` is the current origin, and a name without a trailing dot gets the origin appended, in
|
|
89
|
-
and in the name fields of typed
|
|
90
|
-
|
|
91
|
-
to one canonical spelling; the root is `"."`.
|
|
118
|
+
- `"…"` quotes a field, with `\"`, `\\` and `\DDD` escapes.
|
|
119
|
+
- `(` … `)` joins lines into one entry, from `line` to `endLine`.
|
|
120
|
+
- `@` is the current origin, and a name without a trailing dot gets the origin appended, in
|
|
121
|
+
owners and in the name fields of typed data. Names come back absolute, lowercased, without the
|
|
122
|
+
trailing dot; the root is `"."`.
|
|
92
123
|
- A line starting with whitespace takes the previous record's owner.
|
|
93
124
|
- TTL and class are optional, in either order. A TTL is seconds or BIND units (`1h30m`, `2W`), up
|
|
94
|
-
to 2³¹−1
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`$
|
|
125
|
+
to 2³¹−1; a record without one takes `$TTL`, else the previous record's TTL, else
|
|
126
|
+
`options.ttl`.
|
|
127
|
+
- `$ORIGIN`, `$TTL` and `$INCLUDE file [origin]` are read. An included file starts at the given
|
|
128
|
+
or current origin, the origin, `$TTL` and owner revert after it, and includes nest 8 deep.
|
|
98
129
|
- Classes are `IN`, `CH`, `HS`, `CS` and `CLASSnnn`; types are any mnemonic or `TYPEnnn`, and RFC
|
|
99
130
|
3597 generic data (`\# 4 C0000201`) is read for every type.
|
|
100
131
|
|
|
101
|
-
|
|
102
|
-
returned: which of them to keep is the caller's decision.
|
|
103
|
-
|
|
104
|
-
### `stringify(zone, options?): string`
|
|
132
|
+
### `stringify(zone: ZoneInput, options?: StringifyOptions): string`
|
|
105
133
|
|
|
106
|
-
Writes `{ origin?, ttl?, records }` (a parsed `Zone` is one) as one tab-separated line per record
|
|
107
|
-
`$ORIGIN` and `$TTL` lead when the input names them, and a record's TTL is
|
|
108
|
-
`$TTL`. `relative: true` writes names under the origin relative to it and
|
|
109
|
-
otherwise every name is absolute.
|
|
110
|
-
|
|
111
|
-
after `;`; spacing, standalone comments and parentheses are not kept.
|
|
134
|
+
Writes `{ origin?, ttl?, records }` (a parsed `Zone` is one) as one tab-separated line per record,
|
|
135
|
+
ending in a newline. `$ORIGIN` and `$TTL` lead when the input names them, and a record's TTL is
|
|
136
|
+
left out when it equals `$TTL`. `relative: true` writes names under the origin relative to it and
|
|
137
|
+
the apex as `@`; otherwise every name is absolute. Comments are written back after `;`; spacing,
|
|
138
|
+
standalone comments and parentheses are not.
|
|
112
139
|
|
|
113
|
-
### `parseRecordData(type, data): Result<
|
|
140
|
+
### `parseRecordData<Type extends RecordType>(type: Type, data: string): Result<RecordData<Type>, RecordDataError>`
|
|
114
141
|
|
|
115
|
-
Reads RDATA in presentation format into typed fields
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`{ type, data }`. RFC 3597 generic data is decoded for the typed types. Names are not qualified:
|
|
121
|
-
relative names stay relative.
|
|
142
|
+
Reads RDATA in presentation format, or RFC 3597 generic form, into the typed fields of `A`,
|
|
143
|
+
`AAAA`, `CNAME`, `NS`, `PTR`, `DNAME`, `MX`, `TXT`, `CAA`, `SRV`, `SOA` (SOA timers accept TTL
|
|
144
|
+
units), `SVCB` and `HTTPS` (their SvcParams as `params`), `TLSA`, `DS`, `CDS`, `DNSKEY` and
|
|
145
|
+
`CDNSKEY` (with the key's `keyTag`). Any other type returns `{ type, data }` as written. Names come back lowercased
|
|
146
|
+
without the trailing dot, and a relative name stays relative.
|
|
122
147
|
|
|
123
148
|
```typescript
|
|
124
149
|
parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
|
|
125
150
|
// success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })
|
|
126
151
|
```
|
|
127
152
|
|
|
128
|
-
### `formatRecordData(data): string`
|
|
153
|
+
### `formatRecordData(data: RecordData<RecordType>): string`
|
|
129
154
|
|
|
130
155
|
Prints record data in canonical presentation format, the inverse of `parseRecordData`: names
|
|
131
|
-
absolute with the trailing dot, TXT strings
|
|
132
|
-
printable
|
|
133
|
-
|
|
156
|
+
absolute with the trailing dot, TXT strings, the CAA value and SvcParam text quoted with
|
|
157
|
+
non-printable octets as `\DDD`, hex lowercase, SvcParams in key order, untyped data as is. Two spellings of one record, such as `0 ISSUE "ca.example"` and its
|
|
158
|
+
generic form, print to one string.
|
|
134
159
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
### `canonicalType(type: string): string`
|
|
161
|
+
|
|
162
|
+
The uppercase mnemonic for a type, resolving `TYPEnnn` when it has one: `canonicalType("type16")`
|
|
163
|
+
is `"TXT"`.
|
|
138
164
|
|
|
139
|
-
### `
|
|
165
|
+
### `typeName(code: number): string`
|
|
140
166
|
|
|
141
|
-
|
|
142
|
-
`"TXT"`, and a code without a mnemonic is `"TYPEnnn"`.
|
|
167
|
+
The mnemonic for a type code, or `TYPEnnn` without one: `typeName(16)` is `"TXT"`.
|
|
143
168
|
|
|
144
|
-
###
|
|
169
|
+
### `typeCode(type: string): number | null`
|
|
145
170
|
|
|
146
|
-
|
|
147
|
-
| ----------------- | -------------------------------------------------------------------------------------- |
|
|
148
|
-
| `ZoneFileError` | `code: "too-large"`: the input, with its includes, passed `maxBytes`; `bytes` holds it |
|
|
149
|
-
| `RecordDataError` | `parseRecordData` on data that does not fit the type |
|
|
171
|
+
The code for a mnemonic or `TYPEnnn`, `null` for an unknown mnemonic: `typeCode("HTTPS")` is `65`.
|
|
150
172
|
|
|
151
|
-
###
|
|
173
|
+
### `ZoneFileError`
|
|
152
174
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
| `malformed` | An unterminated quote, unbalanced parentheses, a bad owner or TTL, a missing type or data |
|
|
156
|
-
| `invalid-data` | RDATA that does not fit its type; `message` holds the codec's error |
|
|
157
|
-
| `missing-owner` | A blank owner with no earlier record to take it from |
|
|
158
|
-
| `include` | `$INCLUDE` without the `include` option, a file it returned `null` for, or nesting past 8 |
|
|
159
|
-
| `unsupported-directive` | `$GENERATE` or any other `$` word |
|
|
175
|
+
The failure `parse` returns when the input, with its includes, passes `maxBytes`. `code` is
|
|
176
|
+
`"too-large"` and `bytes` holds the size read.
|
|
160
177
|
|
|
161
|
-
|
|
162
|
-
(the entry as written) and `message`.
|
|
178
|
+
### `RecordDataError`
|
|
163
179
|
|
|
164
|
-
|
|
180
|
+
The failure `parseRecordData` returns for data that does not fit its type.
|
|
165
181
|
|
|
166
|
-
Types
|
|
167
|
-
`RejectionReason`, `ParseOptions`, `ZoneInput`, `RecordInput`, `StringifyOptions`, `RecordType`,
|
|
168
|
-
`TypedRecordType`, `RecordClass`, `RecordData<Type>`, and one data interface per typed type
|
|
169
|
-
(`AData` through `SOAData`) plus `UnknownData`.
|
|
182
|
+
### Types
|
|
170
183
|
|
|
171
|
-
|
|
184
|
+
Every type is exported top-level and under the `ZoneFile` namespace (`ZoneFile.Zone`).
|
|
172
185
|
|
|
173
|
-
`
|
|
186
|
+
- `Zone`: `{ origin, records, rejected }`, where `origin` is the origin at the end of the file.
|
|
187
|
+
- `Record`: any record read from a file; `RecordFor<Type>` narrows it to one type, and
|
|
188
|
+
`RecordFields` is what every record carries beside its data (`name`, `ttl`, `class`, `file`,
|
|
189
|
+
`line`, `endLine`, `comment`).
|
|
190
|
+
- `UntypedRecord`: a record of a type without typed fields, with its `data` as written and the
|
|
191
|
+
`origin` its relative names need.
|
|
192
|
+
- `Rejection`: `{ file, line, endLine, input, reason, message }` for an entry that did not become
|
|
193
|
+
a record. `RejectionReason` is `"malformed"`, `"invalid-data"`, `"missing-owner"`, `"include"`
|
|
194
|
+
(no `include` option, a file it returned `null` for, or nesting past 8) or
|
|
195
|
+
`"unsupported-directive"` (`$GENERATE` or any other `$` word).
|
|
196
|
+
- `RecordData<Type>`: the typed fields of a type: `AData`, `AAAAData`, `CNAMEData`, `NSData`,
|
|
197
|
+
`PTRData`, `DNAMEData`, `MXData`, `TXTData`, `CAAData`, `SRVData`, `SOAData`, `SVCBData`,
|
|
198
|
+
`HTTPSData` (with `SvcParams`), `TLSAData`, `DSData`, `CDSData`, `DNSKEYData`, `CDNSKEYData`,
|
|
199
|
+
or `UnknownData`.
|
|
200
|
+
- `RecordType`, `TypedRecordType`, `RecordClass`: type and class names.
|
|
201
|
+
- `ParseOptions`, `StringifyOptions`: the options above.
|
|
202
|
+
- `ZoneInput`, `RecordInput`, `RecordInputFields`: what `stringify` accepts; a record needs only
|
|
203
|
+
`name`, its type and fields, with optional `ttl`, `class` (default `"IN"`) and `comment`.
|
|
204
|
+
|
|
205
|
+
## Pattern: Import Only The Records You Track
|
|
206
|
+
|
|
207
|
+
`parse` returns everything, so an import policy is a filter. Exports from DNS providers often drop
|
|
208
|
+
the trailing dot on fully-qualified names; `relativeNames: "origin-suffix"` reads those as
|
|
209
|
+
absolute.
|
|
174
210
|
|
|
175
211
|
```typescript
|
|
176
|
-
import
|
|
212
|
+
import { parse } from "@sdxc/zone-file";
|
|
177
213
|
import { isFailure } from "@sdxc/result";
|
|
178
214
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
(
|
|
184
|
-
record
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
215
|
+
function importZone(text: string, domain: string) {
|
|
216
|
+
let parsed = parse(text, { origin: domain, relativeNames: "origin-suffix" });
|
|
217
|
+
if (isFailure(parsed)) return { records: [], errors: [parsed.error.message] };
|
|
218
|
+
|
|
219
|
+
let records = parsed.data.records.filter(
|
|
220
|
+
(record) =>
|
|
221
|
+
record.class === "IN" &&
|
|
222
|
+
["A", "AAAA", "MX", "TXT"].includes(record.type) &&
|
|
223
|
+
(record.name === domain || record.name.endsWith(`.${domain}`)),
|
|
224
|
+
);
|
|
225
|
+
let errors = parsed.data.rejected.map((entry) => `Line ${entry.line}: ${entry.message}`);
|
|
226
|
+
return { records, errors };
|
|
227
|
+
}
|
|
188
228
|
```
|
|
189
229
|
|
|
190
230
|
## Pattern: Normalize A Zone File For Review
|
|
191
231
|
|
|
192
|
-
Reading and printing gives every zone one spelling —
|
|
232
|
+
Reading and printing gives every zone one spelling — lowercased names, canonical addresses, quoted
|
|
193
233
|
TXT — so two exports diff only where their records differ.
|
|
194
234
|
|
|
195
235
|
```typescript
|
|
196
|
-
import
|
|
236
|
+
import { parse, stringify } from "@sdxc/zone-file";
|
|
197
237
|
import { unwrap } from "@sdxc/result";
|
|
198
238
|
|
|
199
239
|
function normalize(text: string, origin: string): string {
|
|
200
|
-
let zone = unwrap(
|
|
201
|
-
let records = [...zone.records].sort(
|
|
202
|
-
|
|
240
|
+
let zone = unwrap(parse(text, { origin }));
|
|
241
|
+
let records = [...zone.records].sort(
|
|
242
|
+
(a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type),
|
|
243
|
+
);
|
|
244
|
+
return stringify({ origin, records }, { relative: true });
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Pattern: Refuse A File With Any Bad Line
|
|
249
|
+
|
|
250
|
+
For an all-or-nothing import, treat any rejection as a failure and report each with its line.
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import type { Result } from "@sdxc/result";
|
|
254
|
+
import type { Zone } from "@sdxc/zone-file";
|
|
255
|
+
|
|
256
|
+
import { parse } from "@sdxc/zone-file";
|
|
257
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
258
|
+
|
|
259
|
+
function parseStrict(text: string, origin: string): Result<Zone, Error> {
|
|
260
|
+
let parsed = parse(text, { origin, maxBytes: 256 * 1024 });
|
|
261
|
+
if (isFailure(parsed)) return parsed;
|
|
262
|
+
|
|
263
|
+
let { rejected } = parsed.data;
|
|
264
|
+
if (rejected.length === 0) return success(parsed.data);
|
|
265
|
+
|
|
266
|
+
let lines = rejected.map((entry) => `Line ${entry.line} (${entry.reason}): ${entry.input}`);
|
|
267
|
+
return failure(new Error(lines.join("\n")));
|
|
203
268
|
}
|
|
204
269
|
```
|
|
205
270
|
|
|
206
271
|
## Pattern: Read A Zone With Includes
|
|
207
272
|
|
|
273
|
+
The `include` callback supplies each `$INCLUDE`d file; records from it carry its name as `file`.
|
|
274
|
+
|
|
208
275
|
```typescript
|
|
209
276
|
import { readFileSync } from "node:fs";
|
|
210
277
|
import { join } from "node:path";
|
|
211
278
|
|
|
212
|
-
import
|
|
279
|
+
import { parse } from "@sdxc/zone-file";
|
|
213
280
|
|
|
214
|
-
let parsed =
|
|
281
|
+
let parsed = parse(readFileSync("zones/example.com.zone", "utf8"), {
|
|
215
282
|
origin: "example.com",
|
|
283
|
+
ttl: 3600,
|
|
216
284
|
include: (fileName) => readFileSync(join("zones", fileName), "utf8"),
|
|
217
285
|
});
|
|
218
286
|
```
|
|
219
287
|
|
|
288
|
+
## Pattern: Compare A Zone File With Live DNS
|
|
289
|
+
|
|
290
|
+
A resolver answers with a type code and RDATA that may be in generic form. Reading both sides with
|
|
291
|
+
`parseRecordData` and printing with `formatRecordData` gives one key per record, whichever way it
|
|
292
|
+
was spelled.
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
import { formatRecordData, parse, parseRecordData, typeName } from "@sdxc/zone-file";
|
|
296
|
+
import { isSuccess, unwrap } from "@sdxc/result";
|
|
297
|
+
|
|
298
|
+
interface Answer {
|
|
299
|
+
name: string;
|
|
300
|
+
type: number;
|
|
301
|
+
data: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function missingFromDns(text: string, origin: string, name: string, type: string) {
|
|
305
|
+
let url = new URL("https://cloudflare-dns.com/dns-query");
|
|
306
|
+
url.searchParams.set("name", name);
|
|
307
|
+
url.searchParams.set("type", type);
|
|
308
|
+
let response = await fetch(url, { headers: { Accept: "application/dns-json" } });
|
|
309
|
+
let body = (await response.json()) as { Answer?: Answer[] };
|
|
310
|
+
|
|
311
|
+
let live = new Set<string>();
|
|
312
|
+
for (let answer of body.Answer ?? []) {
|
|
313
|
+
let data = parseRecordData(typeName(answer.type), answer.data);
|
|
314
|
+
if (isSuccess(data)) live.add(formatRecordData(data.data));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return unwrap(parse(text, { origin }))
|
|
318
|
+
.records.filter((record) => record.name === name && record.type === type)
|
|
319
|
+
.filter((record) => !live.has(formatRecordData(record)));
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
220
323
|
## Versioning
|
|
221
324
|
|
|
222
325
|
Releases are dated rather than semantic. A version is the UTC date it was published,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP address codecs for A and AAAA data and the SVCB address hints: the strict dotted-quad
|
|
3
|
+
* reading, RFC 5952 canonical IPv6, and the octets each spells on the wire.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/** Whether text is an IPv4 address in the dotted-quad form A data takes. */
|
|
9
|
+
export declare function isIpv4(value: string): boolean;
|
|
10
|
+
/** An IPv6 literal in RFC 5952 canonical form, or `null` when the text is not one. */
|
|
11
|
+
export declare function canonicalIpv6(value: string): string | null;
|
|
12
|
+
/** The four octets of a dotted-quad IPv4 address, or `null` when the text is not one. */
|
|
13
|
+
export declare function ipv4Octets(value: string): Uint8Array | null;
|
|
14
|
+
/** The sixteen octets of an IPv6 address, or `null` when the text is not one. */
|
|
15
|
+
export declare function ipv6Octets(value: string): Uint8Array | null;
|
|
16
|
+
/** Prints sixteen octets as an RFC 5952 canonical IPv6 address. */
|
|
17
|
+
export declare function printIpv6(octets: Uint8Array): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP address codecs for A and AAAA data and the SVCB address hints: the strict dotted-quad
|
|
3
|
+
* reading, RFC 5952 canonical IPv6, and the octets each spells on the wire.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* A dotted-quad IPv4 address without leading zeros, which `inet_aton` would read as octal
|
|
10
|
+
* and browsers as decimal, so an address carrying them has no single meaning.
|
|
11
|
+
*/
|
|
12
|
+
const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
|
13
|
+
/** Whether text is an IPv4 address in the dotted-quad form A data takes. */
|
|
14
|
+
export function isIpv4(value) {
|
|
15
|
+
return IPV4_PATTERN.test(value);
|
|
16
|
+
}
|
|
17
|
+
/** An IPv6 literal in RFC 5952 canonical form, or `null` when the text is not one. */
|
|
18
|
+
export function canonicalIpv6(value) {
|
|
19
|
+
if (!value.includes(":"))
|
|
20
|
+
return null;
|
|
21
|
+
let url = URL.parse(`http://[${value}]/`);
|
|
22
|
+
return url ? url.hostname.slice(1, -1) : null;
|
|
23
|
+
}
|
|
24
|
+
/** The four octets of a dotted-quad IPv4 address, or `null` when the text is not one. */
|
|
25
|
+
export function ipv4Octets(value) {
|
|
26
|
+
return isIpv4(value) ? Uint8Array.from(value.split("."), Number) : null;
|
|
27
|
+
}
|
|
28
|
+
/** The sixteen octets of an IPv6 address, or `null` when the text is not one. */
|
|
29
|
+
export function ipv6Octets(value) {
|
|
30
|
+
let canonical = canonicalIpv6(value);
|
|
31
|
+
if (canonical === null)
|
|
32
|
+
return null;
|
|
33
|
+
let [head = "", tail] = canonical.split("::");
|
|
34
|
+
let leading = head === "" ? [] : head.split(":");
|
|
35
|
+
let trailing = tail === undefined || tail === "" ? [] : tail.split(":");
|
|
36
|
+
let zeros = Array.from({ length: 8 - leading.length - trailing.length }, () => "0");
|
|
37
|
+
let groups = tail === undefined ? leading : [...leading, ...zeros, ...trailing];
|
|
38
|
+
let octets = new Uint8Array(16);
|
|
39
|
+
groups.forEach((group, index) => {
|
|
40
|
+
let word = Number.parseInt(group, 16);
|
|
41
|
+
octets[index * 2] = word >> 8;
|
|
42
|
+
octets[index * 2 + 1] = word & 0xff;
|
|
43
|
+
});
|
|
44
|
+
return octets;
|
|
45
|
+
}
|
|
46
|
+
/** Prints sixteen octets as an RFC 5952 canonical IPv6 address. */
|
|
47
|
+
export function printIpv6(octets) {
|
|
48
|
+
let groups = Array.from({ length: 8 }, (_, index) => (((octets[index * 2] ?? 0) << 8) | (octets[index * 2 + 1] ?? 0)).toString(16));
|
|
49
|
+
return canonicalIpv6(groups.join(":")) ?? groups.join(":");
|
|
50
|
+
}
|
package/dist/dnssec.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DNSSEC pieces DS and DNSKEY data share: the algorithm mnemonics RFC 4034 lets a zone
|
|
3
|
+
* file write in place of a number, and the key tag a DS record names its DNSKEY by, so a
|
|
4
|
+
* delegation's DS set can be matched against the child's keys.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** An algorithm field as a number, read from its decimal or its mnemonic; `null` for neither. */
|
|
10
|
+
export declare function readAlgorithm(field: string | undefined): number | null;
|
|
11
|
+
/**
|
|
12
|
+
* The key tag of DNSKEY RDATA (RFC 4034 Appendix B): the octets summed as big-endian 16-bit
|
|
13
|
+
* words with the carry folded back in once, and for an RSA/MD5 (algorithm 1) key the
|
|
14
|
+
* modulus's second- and third-to-last octets, as that appendix specifies for it.
|
|
15
|
+
*
|
|
16
|
+
* @param rdata - The DNSKEY RDATA in wire form: flags, protocol, algorithm and key.
|
|
17
|
+
*/
|
|
18
|
+
export declare function keyTag(rdata: Uint8Array): number;
|
package/dist/dnssec.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DNSSEC pieces DS and DNSKEY data share: the algorithm mnemonics RFC 4034 lets a zone
|
|
3
|
+
* file write in place of a number, and the key tag a DS record names its DNSKEY by, so a
|
|
4
|
+
* delegation's DS set can be matched against the child's keys.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** The DNSSEC algorithm numbers by IANA mnemonic; the uppercase mnemonic reads as its number. */
|
|
10
|
+
const ALGORITHMS = {
|
|
11
|
+
RSAMD5: 1,
|
|
12
|
+
DH: 2,
|
|
13
|
+
DSA: 3,
|
|
14
|
+
RSASHA1: 5,
|
|
15
|
+
"DSA-NSEC3-SHA1": 6,
|
|
16
|
+
"RSASHA1-NSEC3-SHA1": 7,
|
|
17
|
+
RSASHA256: 8,
|
|
18
|
+
RSASHA512: 10,
|
|
19
|
+
"ECC-GOST": 12,
|
|
20
|
+
ECDSAP256SHA256: 13,
|
|
21
|
+
ECDSAP384SHA384: 14,
|
|
22
|
+
ED25519: 15,
|
|
23
|
+
ED448: 16,
|
|
24
|
+
INDIRECT: 252,
|
|
25
|
+
PRIVATEDNS: 253,
|
|
26
|
+
PRIVATEOID: 254,
|
|
27
|
+
};
|
|
28
|
+
/** An algorithm field as a number, read from its decimal or its mnemonic; `null` for neither. */
|
|
29
|
+
export function readAlgorithm(field) {
|
|
30
|
+
if (field === undefined)
|
|
31
|
+
return null;
|
|
32
|
+
if (/^\d+$/.test(field)) {
|
|
33
|
+
let number = Number(field);
|
|
34
|
+
return number <= 0xff ? number : null;
|
|
35
|
+
}
|
|
36
|
+
return ALGORITHMS[field.toUpperCase()] ?? null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The key tag of DNSKEY RDATA (RFC 4034 Appendix B): the octets summed as big-endian 16-bit
|
|
40
|
+
* words with the carry folded back in once, and for an RSA/MD5 (algorithm 1) key the
|
|
41
|
+
* modulus's second- and third-to-last octets, as that appendix specifies for it.
|
|
42
|
+
*
|
|
43
|
+
* @param rdata - The DNSKEY RDATA in wire form: flags, protocol, algorithm and key.
|
|
44
|
+
*/
|
|
45
|
+
export function keyTag(rdata) {
|
|
46
|
+
if (rdata[3] === 1 && rdata.length >= 7)
|
|
47
|
+
return ((rdata[rdata.length - 3] ?? 0) << 8) | (rdata[rdata.length - 2] ?? 0);
|
|
48
|
+
let sum = 0;
|
|
49
|
+
for (let index = 0; index < rdata.length; index++)
|
|
50
|
+
sum += index % 2 === 0 ? (rdata[index] ?? 0) << 8 : (rdata[index] ?? 0);
|
|
51
|
+
sum += (sum >> 16) & 0xffff;
|
|
52
|
+
return sum & 0xffff;
|
|
53
|
+
}
|
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type * as ZoneFile from "./types.js";
|
|
10
10
|
/**
|
|
11
|
-
* Prints a record's data in canonical presentation format
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `parseRecordData(data.type, formatRecordData(data))` gives back `data`.
|
|
11
|
+
* Prints a record's data in canonical presentation format: names absolute, free text quoted
|
|
12
|
+
* with every octet outside printable ASCII as `\DDD`, hex lowercase, SvcParams in key order,
|
|
13
|
+
* untyped data as is. `parseRecordData(data.type, formatRecordData(data))` gives back `data`.
|
|
15
14
|
*
|
|
16
15
|
* @param data - Record data, as `parseRecordData` produces it.
|
|
17
16
|
* @returns The RDATA in presentation format.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { absoluteName } from "./names.js";
|
|
2
|
+
import { printSvcParams } from "./svc-params.js";
|
|
2
3
|
/** Encodes a character-string into the octets `\DDD` escapes stand for. */
|
|
3
4
|
const ENCODER = new TextEncoder();
|
|
4
5
|
/**
|
|
5
|
-
* Prints a record's data in canonical presentation format
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `parseRecordData(data.type, formatRecordData(data))` gives back `data`.
|
|
6
|
+
* Prints a record's data in canonical presentation format: names absolute, free text quoted
|
|
7
|
+
* with every octet outside printable ASCII as `\DDD`, hex lowercase, SvcParams in key order,
|
|
8
|
+
* untyped data as is. `parseRecordData(data.type, formatRecordData(data))` gives back `data`.
|
|
9
9
|
*
|
|
10
10
|
* @param data - Record data, as `parseRecordData` produces it.
|
|
11
11
|
* @returns The RDATA in presentation format.
|
|
@@ -62,6 +62,26 @@ export function printRecordData(data, printName) {
|
|
|
62
62
|
soa.minimum,
|
|
63
63
|
].join(" ");
|
|
64
64
|
}
|
|
65
|
+
case "SVCB":
|
|
66
|
+
case "HTTPS": {
|
|
67
|
+
let { priority, target, params } = data;
|
|
68
|
+
let printed = printSvcParams(params, quote);
|
|
69
|
+
return `${priority} ${printName(target)}${printed ? ` ${printed}` : ""}`;
|
|
70
|
+
}
|
|
71
|
+
case "TLSA": {
|
|
72
|
+
let tlsa = data;
|
|
73
|
+
return `${tlsa.usage} ${tlsa.selector} ${tlsa.matchingType} ${tlsa.certificateAssociation}`;
|
|
74
|
+
}
|
|
75
|
+
case "DS":
|
|
76
|
+
case "CDS": {
|
|
77
|
+
let ds = data;
|
|
78
|
+
return `${ds.keyTag} ${ds.algorithm} ${ds.digestType} ${ds.digest}`;
|
|
79
|
+
}
|
|
80
|
+
case "DNSKEY":
|
|
81
|
+
case "CDNSKEY": {
|
|
82
|
+
let key = data;
|
|
83
|
+
return `${key.flags} ${key.protocol} ${key.algorithm} ${key.publicKey}`;
|
|
84
|
+
}
|
|
65
85
|
default:
|
|
66
86
|
return String(data.data);
|
|
67
87
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,5 +12,5 @@ export { RecordDataError, ZoneFileError } from "./errors.js";
|
|
|
12
12
|
export { formatRecordData } from "./format-record-data.js";
|
|
13
13
|
export { parse } from "./parse.js";
|
|
14
14
|
export { parseRecordData } from "./parse-record-data.js";
|
|
15
|
-
export { canonicalType, typeName } from "./record-types.js";
|
|
15
|
+
export { canonicalType, typeCode, typeName } from "./record-types.js";
|
|
16
16
|
export { stringify } from "./stringify.js";
|