@sdxc/zone-file 2026.10.13 → 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 +12 -5
- 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 +3 -3
package/README.md
CHANGED
|
@@ -140,8 +140,9 @@ standalone comments and parentheses are not.
|
|
|
140
140
|
### `parseRecordData<Type extends RecordType>(type: Type, data: string): Result<RecordData<Type>, RecordDataError>`
|
|
141
141
|
|
|
142
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
|
|
144
|
-
|
|
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
|
|
145
146
|
without the trailing dot, and a relative name stays relative.
|
|
146
147
|
|
|
147
148
|
```typescript
|
|
@@ -152,8 +153,8 @@ parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
|
|
|
152
153
|
### `formatRecordData(data: RecordData<RecordType>): string`
|
|
153
154
|
|
|
154
155
|
Prints record data in canonical presentation format, the inverse of `parseRecordData`: names
|
|
155
|
-
absolute with the trailing dot, TXT strings
|
|
156
|
-
`\DDD`, untyped data as is. Two spellings of one record, such as `0 ISSUE "ca.example"` and its
|
|
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
|
|
157
158
|
generic form, print to one string.
|
|
158
159
|
|
|
159
160
|
### `canonicalType(type: string): string`
|
|
@@ -165,6 +166,10 @@ is `"TXT"`.
|
|
|
165
166
|
|
|
166
167
|
The mnemonic for a type code, or `TYPEnnn` without one: `typeName(16)` is `"TXT"`.
|
|
167
168
|
|
|
169
|
+
### `typeCode(type: string): number | null`
|
|
170
|
+
|
|
171
|
+
The code for a mnemonic or `TYPEnnn`, `null` for an unknown mnemonic: `typeCode("HTTPS")` is `65`.
|
|
172
|
+
|
|
168
173
|
### `ZoneFileError`
|
|
169
174
|
|
|
170
175
|
The failure `parse` returns when the input, with its includes, passes `maxBytes`. `code` is
|
|
@@ -189,7 +194,9 @@ Every type is exported top-level and under the `ZoneFile` namespace (`ZoneFile.Z
|
|
|
189
194
|
(no `include` option, a file it returned `null` for, or nesting past 8) or
|
|
190
195
|
`"unsupported-directive"` (`$GENERATE` or any other `$` word).
|
|
191
196
|
- `RecordData<Type>`: the typed fields of a type: `AData`, `AAAAData`, `CNAMEData`, `NSData`,
|
|
192
|
-
`PTRData`, `DNAMEData`, `MXData`, `TXTData`, `CAAData`, `SRVData`, `SOAData`,
|
|
197
|
+
`PTRData`, `DNAMEData`, `MXData`, `TXTData`, `CAAData`, `SRVData`, `SOAData`, `SVCBData`,
|
|
198
|
+
`HTTPSData` (with `SvcParams`), `TLSAData`, `DSData`, `CDSData`, `DNSKEYData`, `CDNSKEYData`,
|
|
199
|
+
or `UnknownData`.
|
|
193
200
|
- `RecordType`, `TypedRecordType`, `RecordClass`: type and class names.
|
|
194
201
|
- `ParseOptions`, `StringifyOptions`: the options above.
|
|
195
202
|
- `ZoneInput`, `RecordInput`, `RecordInputFields`: what `stringify` accepts; a record needs only
|
|
@@ -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";
|
package/dist/index.js
CHANGED
|
@@ -10,5 +10,5 @@ export { RecordDataError, ZoneFileError } from "./errors.js";
|
|
|
10
10
|
export { formatRecordData } from "./format-record-data.js";
|
|
11
11
|
export { parse } from "./parse.js";
|
|
12
12
|
export { parseRecordData } from "./parse-record-data.js";
|
|
13
|
-
export { canonicalType, typeName } from "./record-types.js";
|
|
13
|
+
export { canonicalType, typeCode, typeName } from "./record-types.js";
|
|
14
14
|
export { stringify } from "./stringify.js";
|
package/dist/octets.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hex and base64 codecs for the binary fields of DNSSEC, DANE and SVCB records, which the
|
|
3
|
+
* presentation format writes as text a zone file may split across whitespace and lines.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/** The octets a hex string spells, whitespace ignored; `null` for an odd or non-hex run, or none. */
|
|
9
|
+
export declare function decodeHex(text: string): Uint8Array | null;
|
|
10
|
+
/** Octets as lowercase hex, the spelling DS digests and TLSA data compare in. */
|
|
11
|
+
export declare function encodeHex(octets: Uint8Array): string;
|
|
12
|
+
/** The octets a base64 string spells, whitespace ignored; `null` for text that is not base64, or none. */
|
|
13
|
+
export declare function decodeBase64(text: string): Uint8Array | null;
|
|
14
|
+
/** Octets as padded base64, the spelling DNSKEY keys and SVCB `ech` print in. */
|
|
15
|
+
export declare function encodeBase64(octets: Uint8Array): string;
|
package/dist/octets.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hex and base64 codecs for the binary fields of DNSSEC, DANE and SVCB records, which the
|
|
3
|
+
* presentation format writes as text a zone file may split across whitespace and lines.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/** Hex text in pairs, with any whitespace between digits already removed. */
|
|
9
|
+
const HEX_PATTERN = /^(?:[0-9a-fA-F]{2})+$/;
|
|
10
|
+
/** Canonical base64: complete quads, padding only at the end. */
|
|
11
|
+
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
12
|
+
/** The octets a hex string spells, whitespace ignored; `null` for an odd or non-hex run, or none. */
|
|
13
|
+
export function decodeHex(text) {
|
|
14
|
+
let hex = text.replace(/\s+/g, "");
|
|
15
|
+
if (!HEX_PATTERN.test(hex))
|
|
16
|
+
return null;
|
|
17
|
+
let octets = new Uint8Array(hex.length / 2);
|
|
18
|
+
for (let index = 0; index < octets.length; index++)
|
|
19
|
+
octets[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
20
|
+
return octets;
|
|
21
|
+
}
|
|
22
|
+
/** Octets as lowercase hex, the spelling DS digests and TLSA data compare in. */
|
|
23
|
+
export function encodeHex(octets) {
|
|
24
|
+
return Array.from(octets, (octet) => octet.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
}
|
|
26
|
+
/** The octets a base64 string spells, whitespace ignored; `null` for text that is not base64, or none. */
|
|
27
|
+
export function decodeBase64(text) {
|
|
28
|
+
let base64 = text.replace(/\s+/g, "");
|
|
29
|
+
if (base64.length === 0 || !BASE64_PATTERN.test(base64))
|
|
30
|
+
return null;
|
|
31
|
+
let binary = atob(base64);
|
|
32
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
33
|
+
}
|
|
34
|
+
/** Octets as padded base64, the spelling DNSKEY keys and SVCB `ech` print in. */
|
|
35
|
+
export function encodeBase64(octets) {
|
|
36
|
+
return btoa(Array.from(octets, (octet) => String.fromCharCode(octet)).join(""));
|
|
37
|
+
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import { canonicalIpv6, isIpv4, printIpv6 } from "./addresses.js";
|
|
2
3
|
import { decodeOctets, readCharacterStrings } from "./character-strings.js";
|
|
4
|
+
import { keyTag, readAlgorithm } from "./dnssec.js";
|
|
3
5
|
import { RecordDataError } from "./errors.js";
|
|
4
6
|
import { canonicalName, printLabels } from "./names.js";
|
|
7
|
+
import { decodeBase64, decodeHex, encodeBase64, encodeHex } from "./octets.js";
|
|
5
8
|
import { canonicalType } from "./record-types.js";
|
|
9
|
+
import { readSvcParams, readWireSvcParams } from "./svc-params.js";
|
|
6
10
|
import { readTtl } from "./ttl.js";
|
|
7
|
-
/**
|
|
8
|
-
* A dotted-quad IPv4 address without leading zeros, which `inet_aton` would read as octal
|
|
9
|
-
* and browsers as decimal, so an address carrying them has no single meaning.
|
|
10
|
-
*/
|
|
11
|
-
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}$/;
|
|
12
11
|
/**
|
|
13
12
|
* Reads a record's data into its typed fields; untyped types come back as `{ type, data }`.
|
|
14
13
|
* Names come back canonical (lowercased, escapes resolved, no trailing dot, the root as
|
|
@@ -39,19 +38,41 @@ function readNumber(field, max) {
|
|
|
39
38
|
let number = Number(field);
|
|
40
39
|
return number <= max ? number : null;
|
|
41
40
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The fields of DNSKEY data from its wire octets, the key tag computed over them, so the
|
|
43
|
+
* presentation and generic spellings of one key agree on every field.
|
|
44
|
+
*/
|
|
45
|
+
function dnskeyFields(rdata) {
|
|
46
|
+
let flags = ((rdata[0] ?? 0) << 8) | (rdata[1] ?? 0);
|
|
47
|
+
return {
|
|
48
|
+
flags,
|
|
49
|
+
zoneKey: (flags & 0x0100) !== 0,
|
|
50
|
+
secureEntryPoint: (flags & 0x0001) !== 0,
|
|
51
|
+
protocol: rdata[2] ?? 0,
|
|
52
|
+
algorithm: rdata[3] ?? 0,
|
|
53
|
+
publicKey: encodeBase64(rdata.subarray(4)),
|
|
54
|
+
keyTag: keyTag(rdata),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Reads the leading `count` whitespace-separated fields and the rest of the text after them. */
|
|
58
|
+
function splitLeading(value, count) {
|
|
59
|
+
let fields = [];
|
|
60
|
+
let rest = value;
|
|
61
|
+
for (let index = 0; index < count; index++) {
|
|
62
|
+
let match = /^(\S+)\s*/.exec(rest);
|
|
63
|
+
if (!match)
|
|
64
|
+
break;
|
|
65
|
+
fields.push(match[1] ?? "");
|
|
66
|
+
rest = rest.slice(match[0].length);
|
|
67
|
+
}
|
|
68
|
+
return [fields, rest];
|
|
48
69
|
}
|
|
49
70
|
/** Reads presentation-format data for the typed record types. */
|
|
50
71
|
function parsePresentation(type, value) {
|
|
51
72
|
let fields = value.split(/\s+/).filter((field) => field.length > 0);
|
|
52
73
|
switch (type) {
|
|
53
74
|
case "A":
|
|
54
|
-
return
|
|
75
|
+
return isIpv4(value) ? success({ type, address: value }) : invalid(type, value);
|
|
55
76
|
case "AAAA": {
|
|
56
77
|
let address = canonicalIpv6(value);
|
|
57
78
|
return address ? success({ type, address }) : invalid(type, value);
|
|
@@ -133,6 +154,50 @@ function parsePresentation(type, value) {
|
|
|
133
154
|
minimum,
|
|
134
155
|
});
|
|
135
156
|
}
|
|
157
|
+
case "SVCB":
|
|
158
|
+
case "HTTPS": {
|
|
159
|
+
let [[priorityField, targetField], rest] = splitLeading(value, 2);
|
|
160
|
+
let priority = readNumber(priorityField, 0xffff);
|
|
161
|
+
let target = targetField === undefined ? null : canonicalName(targetField);
|
|
162
|
+
let params = readSvcParams(rest);
|
|
163
|
+
if (priority === null || target === null || params === null)
|
|
164
|
+
return invalid(type, value);
|
|
165
|
+
return success({ type, priority, target, params });
|
|
166
|
+
}
|
|
167
|
+
case "TLSA": {
|
|
168
|
+
let [leading, rest] = splitLeading(value, 3);
|
|
169
|
+
let [usage, selector, matchingType] = leading.map((field) => readNumber(field, 0xff));
|
|
170
|
+
let association = decodeHex(rest);
|
|
171
|
+
if (usage == null || selector == null || matchingType == null || !association)
|
|
172
|
+
return invalid(type, value);
|
|
173
|
+
let certificateAssociation = encodeHex(association);
|
|
174
|
+
return success({ type, usage, selector, matchingType, certificateAssociation });
|
|
175
|
+
}
|
|
176
|
+
case "DS":
|
|
177
|
+
case "CDS": {
|
|
178
|
+
let [[tagField, algorithmField, digestTypeField], rest] = splitLeading(value, 3);
|
|
179
|
+
let keyTag = readNumber(tagField, 0xffff);
|
|
180
|
+
let algorithm = readAlgorithm(algorithmField);
|
|
181
|
+
let digestType = readNumber(digestTypeField, 0xff);
|
|
182
|
+
let digest = decodeHex(rest);
|
|
183
|
+
if (keyTag === null || algorithm === null || digestType === null || !digest)
|
|
184
|
+
return invalid(type, value);
|
|
185
|
+
return success({ type, keyTag, algorithm, digestType, digest: encodeHex(digest) });
|
|
186
|
+
}
|
|
187
|
+
case "DNSKEY":
|
|
188
|
+
case "CDNSKEY": {
|
|
189
|
+
let [[flagsField, protocolField, algorithmField], rest] = splitLeading(value, 3);
|
|
190
|
+
let flags = readNumber(flagsField, 0xffff);
|
|
191
|
+
let protocol = readNumber(protocolField, 0xff);
|
|
192
|
+
let algorithm = readAlgorithm(algorithmField);
|
|
193
|
+
let key = decodeBase64(rest);
|
|
194
|
+
if (flags === null || protocol === null || algorithm === null || !key)
|
|
195
|
+
return invalid(type, value);
|
|
196
|
+
let rdata = new Uint8Array(4 + key.length);
|
|
197
|
+
rdata.set([flags >> 8, flags & 0xff, protocol, algorithm]);
|
|
198
|
+
rdata.set(key, 4);
|
|
199
|
+
return success({ type, ...dnskeyFields(rdata) });
|
|
200
|
+
}
|
|
136
201
|
default:
|
|
137
202
|
return success({ type, data: value });
|
|
138
203
|
}
|
|
@@ -187,10 +252,7 @@ class WireReader {
|
|
|
187
252
|
}
|
|
188
253
|
case "AAAA": {
|
|
189
254
|
let bytes = this.#bytes(16);
|
|
190
|
-
|
|
191
|
-
return null;
|
|
192
|
-
let groups = Array.from({ length: 8 }, (_, index) => (((bytes[index * 2] ?? 0) << 8) | (bytes[index * 2 + 1] ?? 0)).toString(16));
|
|
193
|
-
return { address: canonicalIpv6(groups.join(":")) ?? groups.join(":") };
|
|
255
|
+
return bytes ? { address: printIpv6(bytes) } : null;
|
|
194
256
|
}
|
|
195
257
|
case "CNAME":
|
|
196
258
|
case "PTR":
|
|
@@ -250,6 +312,39 @@ class WireReader {
|
|
|
250
312
|
let [serial, refresh, retry, expire, minimum] = numbers;
|
|
251
313
|
return { primary, mailbox, serial, refresh, retry, expire, minimum };
|
|
252
314
|
}
|
|
315
|
+
case "SVCB":
|
|
316
|
+
case "HTTPS": {
|
|
317
|
+
let priority = this.#uint(2);
|
|
318
|
+
let target = this.#name();
|
|
319
|
+
let params = readWireSvcParams(this.#rest());
|
|
320
|
+
if (priority === null || target === null || params === null)
|
|
321
|
+
return null;
|
|
322
|
+
return { priority, target, params };
|
|
323
|
+
}
|
|
324
|
+
case "TLSA": {
|
|
325
|
+
let [usage, selector, matchingType] = [this.#uint(1), this.#uint(1), this.#uint(1)];
|
|
326
|
+
let association = this.#rest();
|
|
327
|
+
if (usage === null || selector === null || matchingType === null)
|
|
328
|
+
return null;
|
|
329
|
+
if (association.length === 0)
|
|
330
|
+
return null;
|
|
331
|
+
return { usage, selector, matchingType, certificateAssociation: encodeHex(association) };
|
|
332
|
+
}
|
|
333
|
+
case "DS":
|
|
334
|
+
case "CDS": {
|
|
335
|
+
let [keyTag, algorithm, digestType] = [this.#uint(2), this.#uint(1), this.#uint(1)];
|
|
336
|
+
let digest = this.#rest();
|
|
337
|
+
if (keyTag === null || algorithm === null || digestType === null)
|
|
338
|
+
return null;
|
|
339
|
+
if (digest.length === 0)
|
|
340
|
+
return null;
|
|
341
|
+
return { keyTag, algorithm, digestType, digest: encodeHex(digest) };
|
|
342
|
+
}
|
|
343
|
+
case "DNSKEY":
|
|
344
|
+
case "CDNSKEY": {
|
|
345
|
+
let rdata = this.#rest();
|
|
346
|
+
return rdata.length > 4 ? dnskeyFields(rdata) : null;
|
|
347
|
+
}
|
|
253
348
|
default:
|
|
254
349
|
return "unknown";
|
|
255
350
|
}
|
|
@@ -262,6 +357,10 @@ class WireReader {
|
|
|
262
357
|
this.#offset += length;
|
|
263
358
|
return bytes;
|
|
264
359
|
}
|
|
360
|
+
/** Every octet left, which the types ending in a variable-length field read as that field. */
|
|
361
|
+
#rest() {
|
|
362
|
+
return this.#bytes(this.#octets.length - this.#offset) ?? new Uint8Array();
|
|
363
|
+
}
|
|
265
364
|
/** A big-endian unsigned integer of `size` octets. */
|
|
266
365
|
#uint(size) {
|
|
267
366
|
let bytes = this.#bytes(size);
|
package/dist/parse.js
CHANGED
package/dist/record-types.d.ts
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
import type * as ZoneFile from "./types.js";
|
|
9
9
|
/** The mnemonic for a type code, or `TYPEnnn` when it has none here. */
|
|
10
10
|
export declare function typeName(code: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* The IANA code of a type, given as a mnemonic or `TYPEnnn`, so a caller building a query
|
|
13
|
+
* message can name the type the way a zone file does; `null` for a mnemonic it does not know.
|
|
14
|
+
*
|
|
15
|
+
* @example typeCode("HTTPS") // 65
|
|
16
|
+
* @example typeCode("TYPE65280") // 65280
|
|
17
|
+
*/
|
|
18
|
+
export declare function typeCode(type: string): number | null;
|
|
11
19
|
/**
|
|
12
20
|
* The spelling of a type as a record's `type`: the uppercase mnemonic, and a known
|
|
13
21
|
* mnemonic for `TYPEnnn` when one exists.
|
package/dist/record-types.js
CHANGED
|
@@ -43,6 +43,13 @@ const TYPED_TYPES = new Set([
|
|
|
43
43
|
"CAA",
|
|
44
44
|
"SRV",
|
|
45
45
|
"SOA",
|
|
46
|
+
"SVCB",
|
|
47
|
+
"HTTPS",
|
|
48
|
+
"TLSA",
|
|
49
|
+
"DS",
|
|
50
|
+
"CDS",
|
|
51
|
+
"DNSKEY",
|
|
52
|
+
"CDNSKEY",
|
|
46
53
|
]);
|
|
47
54
|
/** The mnemonic for each code in {@link TYPE_CODES}. */
|
|
48
55
|
const TYPE_NAMES = new Map(Object.entries(TYPE_CODES).map(([name, code]) => [code, name]));
|
|
@@ -50,6 +57,22 @@ const TYPE_NAMES = new Map(Object.entries(TYPE_CODES).map(([name, code]) => [cod
|
|
|
50
57
|
export function typeName(code) {
|
|
51
58
|
return TYPE_NAMES.get(code) ?? `TYPE${code}`;
|
|
52
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The IANA code of a type, given as a mnemonic or `TYPEnnn`, so a caller building a query
|
|
62
|
+
* message can name the type the way a zone file does; `null` for a mnemonic it does not know.
|
|
63
|
+
*
|
|
64
|
+
* @example typeCode("HTTPS") // 65
|
|
65
|
+
* @example typeCode("TYPE65280") // 65280
|
|
66
|
+
*/
|
|
67
|
+
export function typeCode(type) {
|
|
68
|
+
let upper = type.trim().toUpperCase();
|
|
69
|
+
let generic = /^TYPE(\d{1,5})$/.exec(upper);
|
|
70
|
+
if (generic) {
|
|
71
|
+
let code = Number(generic[1]);
|
|
72
|
+
return code <= 0xffff ? code : null;
|
|
73
|
+
}
|
|
74
|
+
return TYPE_CODES[upper] ?? null;
|
|
75
|
+
}
|
|
53
76
|
/**
|
|
54
77
|
* The spelling of a type as a record's `type`: the uppercase mnemonic, and a known
|
|
55
78
|
* mnemonic for `TYPEnnn` when one exists.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type * as ZoneFile from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reads the params of SVCB or HTTPS presentation data, the fields after the target.
|
|
4
|
+
* Fails on an unknown or repeated key and on a value its key does not accept.
|
|
5
|
+
*
|
|
6
|
+
* @param text - Zero or more `key[=value]` fields.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readSvcParams(text: string): ZoneFile.SvcParams | null;
|
|
9
|
+
/**
|
|
10
|
+
* Reads the params of SVCB or HTTPS wire data, the octets after the target. Keys must
|
|
11
|
+
* strictly increase, as RFC 9460 requires of the wire form.
|
|
12
|
+
*
|
|
13
|
+
* @param octets - The SvcParams octets, `key length value` repeated.
|
|
14
|
+
*/
|
|
15
|
+
export declare function readWireSvcParams(octets: Uint8Array): ZoneFile.SvcParams | null;
|
|
16
|
+
/**
|
|
17
|
+
* Prints params in canonical presentation form, keys in ascending number order and values
|
|
18
|
+
* quoted wherever they are free text, so two spellings of one record print alike.
|
|
19
|
+
*
|
|
20
|
+
* @param params - The params to print.
|
|
21
|
+
* @param quote - Prints a character-string with its escapes.
|
|
22
|
+
* @returns The fields joined by spaces; empty for no params.
|
|
23
|
+
*/
|
|
24
|
+
export declare function printSvcParams(params: ZoneFile.SvcParams, quote: (text: string) => string): string;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SvcParams codec SVCB and HTTPS share (RFC 9460): presentation `key=value` lists and
|
|
3
|
+
* their wire form both read into one wire-keyed map first, so the two spellings validate
|
|
4
|
+
* by the same rules and come out as the same `SvcParams`.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { isFailure } from "@sdxc/result";
|
|
10
|
+
import { ipv4Octets, ipv6Octets, printIpv6 } from "./addresses.js";
|
|
11
|
+
import { decodeOctets, readCharacterStrings } from "./character-strings.js";
|
|
12
|
+
import { decodeBase64, encodeBase64 } from "./octets.js";
|
|
13
|
+
/** The registered SvcParamKeys by number; any other key is spelled `keyNNNNN`. */
|
|
14
|
+
const KEY_NAMES = {
|
|
15
|
+
0: "mandatory",
|
|
16
|
+
1: "alpn",
|
|
17
|
+
2: "no-default-alpn",
|
|
18
|
+
3: "port",
|
|
19
|
+
4: "ipv4hint",
|
|
20
|
+
5: "ech",
|
|
21
|
+
6: "ipv6hint",
|
|
22
|
+
7: "dohpath",
|
|
23
|
+
8: "ohttp",
|
|
24
|
+
};
|
|
25
|
+
/** The number of each registered key name. */
|
|
26
|
+
const KEY_NUMBERS = new Map(Object.entries(KEY_NAMES).map(([number, name]) => [name, Number(number)]));
|
|
27
|
+
/** Encodes the text of a value into the octets it is on the wire. */
|
|
28
|
+
const ENCODER = new TextEncoder();
|
|
29
|
+
/** The presentation name of a key number. */
|
|
30
|
+
function keyName(key) {
|
|
31
|
+
return KEY_NAMES[key] ?? `key${key}`;
|
|
32
|
+
}
|
|
33
|
+
/** The number of a key name, registered or `keyNNNNN`; `null` for anything else and for the reserved 65535. */
|
|
34
|
+
function keyNumber(name) {
|
|
35
|
+
let generic = /^key(0|[1-9]\d{0,4})$/.exec(name);
|
|
36
|
+
let number = generic ? Number(generic[1]) : (KEY_NUMBERS.get(name) ?? null);
|
|
37
|
+
return number !== null && number < 0xffff ? number : null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Splits presentation params into `key[=value]` fields on whitespace outside quotes,
|
|
41
|
+
* stepping over escaped characters so `\"` and `\ ` stay inside their field.
|
|
42
|
+
*/
|
|
43
|
+
function splitFields(text) {
|
|
44
|
+
let fields = [];
|
|
45
|
+
let field = "";
|
|
46
|
+
let quoted = false;
|
|
47
|
+
for (let index = 0; index < text.length; index++) {
|
|
48
|
+
let char = text[index] ?? "";
|
|
49
|
+
if (char === "\\") {
|
|
50
|
+
field += text.slice(index, index + 2);
|
|
51
|
+
index += 1;
|
|
52
|
+
}
|
|
53
|
+
else if (char === '"') {
|
|
54
|
+
quoted = !quoted;
|
|
55
|
+
field += char;
|
|
56
|
+
}
|
|
57
|
+
else if (!quoted && /\s/.test(char)) {
|
|
58
|
+
if (field.length > 0)
|
|
59
|
+
fields.push(field);
|
|
60
|
+
field = "";
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
field += char;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (field.length > 0)
|
|
67
|
+
fields.push(field);
|
|
68
|
+
return fields;
|
|
69
|
+
}
|
|
70
|
+
/** A value's text with its quoting and escapes removed, `key=` as empty; `null` for more than one character-string. */
|
|
71
|
+
function readValue(raw) {
|
|
72
|
+
if (raw.length === 0)
|
|
73
|
+
return "";
|
|
74
|
+
let strings = readCharacterStrings(raw);
|
|
75
|
+
if (isFailure(strings) || strings.data.length !== 1)
|
|
76
|
+
return null;
|
|
77
|
+
return strings.data[0] ?? null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Splits a value-list (RFC 9460 Appendix A.1) on its unescaped commas, `\,` and `\\`
|
|
81
|
+
* standing for a literal comma and backslash; `null` when any item is empty.
|
|
82
|
+
*/
|
|
83
|
+
function splitValueList(value) {
|
|
84
|
+
let items = [];
|
|
85
|
+
let item = "";
|
|
86
|
+
for (let index = 0; index < value.length; index++) {
|
|
87
|
+
let char = value[index] ?? "";
|
|
88
|
+
if (char === "\\" && index + 1 < value.length) {
|
|
89
|
+
item += value[index + 1];
|
|
90
|
+
index += 1;
|
|
91
|
+
}
|
|
92
|
+
else if (char === ",") {
|
|
93
|
+
items.push(item);
|
|
94
|
+
item = "";
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
item += char;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
items.push(item);
|
|
101
|
+
return items.every((entry) => entry.length > 0) ? items : null;
|
|
102
|
+
}
|
|
103
|
+
/** Joins octet runs into one. */
|
|
104
|
+
function concat(parts) {
|
|
105
|
+
let octets = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
106
|
+
let offset = 0;
|
|
107
|
+
for (let part of parts) {
|
|
108
|
+
octets.set(part, offset);
|
|
109
|
+
offset += part.length;
|
|
110
|
+
}
|
|
111
|
+
return octets;
|
|
112
|
+
}
|
|
113
|
+
/** A 16-bit big-endian integer's two octets. */
|
|
114
|
+
function uint16(value) {
|
|
115
|
+
return Uint8Array.of(value >> 8, value & 0xff);
|
|
116
|
+
}
|
|
117
|
+
/** Reads every item of a value-list through `read`, `null` when the list or any item is invalid. */
|
|
118
|
+
function encodeList(value, read) {
|
|
119
|
+
let items = splitValueList(value);
|
|
120
|
+
if (!items)
|
|
121
|
+
return null;
|
|
122
|
+
let parts = items.map(read);
|
|
123
|
+
return parts.every((part) => part !== null) ? concat(parts) : null;
|
|
124
|
+
}
|
|
125
|
+
/** The wire octets of one key's presentation value; `value` is `null` when the field has no `=`. */
|
|
126
|
+
function encodeValue(key, value) {
|
|
127
|
+
switch (key) {
|
|
128
|
+
case 0: {
|
|
129
|
+
if (value === null)
|
|
130
|
+
return null;
|
|
131
|
+
let items = splitValueList(value);
|
|
132
|
+
let keys = items?.map(keyNumber);
|
|
133
|
+
if (!keys || keys.some((number) => number === null))
|
|
134
|
+
return null;
|
|
135
|
+
return concat(keys.sort((a, b) => a - b).map(uint16));
|
|
136
|
+
}
|
|
137
|
+
case 1:
|
|
138
|
+
return value === null
|
|
139
|
+
? null
|
|
140
|
+
: encodeList(value, (item) => {
|
|
141
|
+
let octets = ENCODER.encode(item);
|
|
142
|
+
return octets.length <= 0xff ? concat([Uint8Array.of(octets.length), octets]) : null;
|
|
143
|
+
});
|
|
144
|
+
case 2:
|
|
145
|
+
case 8:
|
|
146
|
+
return value === null || value === "" ? new Uint8Array() : null;
|
|
147
|
+
case 3:
|
|
148
|
+
return value !== null && /^\d{1,5}$/.test(value) && Number(value) <= 0xffff
|
|
149
|
+
? uint16(Number(value))
|
|
150
|
+
: null;
|
|
151
|
+
case 4:
|
|
152
|
+
return value === null ? null : encodeList(value, ipv4Octets);
|
|
153
|
+
case 5:
|
|
154
|
+
return value === null ? null : decodeBase64(value);
|
|
155
|
+
case 6:
|
|
156
|
+
return value === null ? null : encodeList(value, ipv6Octets);
|
|
157
|
+
default:
|
|
158
|
+
return ENCODER.encode(value ?? "");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Reads the params of SVCB or HTTPS presentation data, the fields after the target.
|
|
163
|
+
* Fails on an unknown or repeated key and on a value its key does not accept.
|
|
164
|
+
*
|
|
165
|
+
* @param text - Zero or more `key[=value]` fields.
|
|
166
|
+
*/
|
|
167
|
+
export function readSvcParams(text) {
|
|
168
|
+
let values = new Map();
|
|
169
|
+
for (let field of splitFields(text)) {
|
|
170
|
+
let separator = field.indexOf("=");
|
|
171
|
+
let key = keyNumber(separator === -1 ? field : field.slice(0, separator));
|
|
172
|
+
if (key === null || values.has(key))
|
|
173
|
+
return null;
|
|
174
|
+
let value = separator === -1 ? null : readValue(field.slice(separator + 1));
|
|
175
|
+
if (separator !== -1 && value === null)
|
|
176
|
+
return null;
|
|
177
|
+
let octets = encodeValue(key, value);
|
|
178
|
+
if (!octets)
|
|
179
|
+
return null;
|
|
180
|
+
values.set(key, octets);
|
|
181
|
+
}
|
|
182
|
+
return decodeSvcParams(new Map([...values].sort(([a], [b]) => a - b)));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Reads the params of SVCB or HTTPS wire data, the octets after the target. Keys must
|
|
186
|
+
* strictly increase, as RFC 9460 requires of the wire form.
|
|
187
|
+
*
|
|
188
|
+
* @param octets - The SvcParams octets, `key length value` repeated.
|
|
189
|
+
*/
|
|
190
|
+
export function readWireSvcParams(octets) {
|
|
191
|
+
let values = new Map();
|
|
192
|
+
let offset = 0;
|
|
193
|
+
let previous = -1;
|
|
194
|
+
while (offset < octets.length) {
|
|
195
|
+
if (offset + 4 > octets.length)
|
|
196
|
+
return null;
|
|
197
|
+
let key = ((octets[offset] ?? 0) << 8) | (octets[offset + 1] ?? 0);
|
|
198
|
+
let length = ((octets[offset + 2] ?? 0) << 8) | (octets[offset + 3] ?? 0);
|
|
199
|
+
offset += 4;
|
|
200
|
+
if (key <= previous || key === 0xffff || offset + length > octets.length)
|
|
201
|
+
return null;
|
|
202
|
+
values.set(key, octets.subarray(offset, offset + length));
|
|
203
|
+
offset += length;
|
|
204
|
+
previous = key;
|
|
205
|
+
}
|
|
206
|
+
return decodeSvcParams(values);
|
|
207
|
+
}
|
|
208
|
+
/** Splits octets into runs of `size`, `null` when they do not divide evenly or are empty. */
|
|
209
|
+
function chunks(octets, size) {
|
|
210
|
+
if (octets.length === 0 || octets.length % size !== 0)
|
|
211
|
+
return null;
|
|
212
|
+
return Array.from({ length: octets.length / size }, (_, index) => octets.subarray(index * size, index * size + size));
|
|
213
|
+
}
|
|
214
|
+
/** Reads length-prefixed ALPN ids, `null` when one is empty or overruns the value. */
|
|
215
|
+
function readAlpn(octets) {
|
|
216
|
+
let ids = [];
|
|
217
|
+
let offset = 0;
|
|
218
|
+
while (offset < octets.length) {
|
|
219
|
+
let length = octets[offset] ?? 0;
|
|
220
|
+
if (length === 0 || offset + 1 + length > octets.length)
|
|
221
|
+
return null;
|
|
222
|
+
ids.push(decodeOctets(octets.subarray(offset + 1, offset + 1 + length)));
|
|
223
|
+
offset += 1 + length;
|
|
224
|
+
}
|
|
225
|
+
return ids.length > 0 ? ids : null;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Turns wire values into `SvcParams`, enforcing what RFC 9460 asks of every key: values of
|
|
229
|
+
* the right shape, and a `mandatory` list of distinct keys that are all present and exclude
|
|
230
|
+
* `mandatory` itself.
|
|
231
|
+
*/
|
|
232
|
+
function decodeSvcParams(values) {
|
|
233
|
+
let params = {};
|
|
234
|
+
for (let [key, value] of values) {
|
|
235
|
+
switch (key) {
|
|
236
|
+
case 0: {
|
|
237
|
+
let keys = chunks(value, 2)?.map((pair) => ((pair[0] ?? 0) << 8) | (pair[1] ?? 0));
|
|
238
|
+
if (!keys)
|
|
239
|
+
return null;
|
|
240
|
+
let increasing = keys.every((number, index) => index === 0 || number > (keys[index - 1] ?? 0));
|
|
241
|
+
if (!increasing || keys.some((number) => number === 0 || !values.has(number)))
|
|
242
|
+
return null;
|
|
243
|
+
params.mandatory = keys.map(keyName);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 1: {
|
|
247
|
+
let alpn = readAlpn(value);
|
|
248
|
+
if (!alpn)
|
|
249
|
+
return null;
|
|
250
|
+
params.alpn = alpn;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case 2:
|
|
254
|
+
case 8:
|
|
255
|
+
if (value.length !== 0)
|
|
256
|
+
return null;
|
|
257
|
+
if (key === 2)
|
|
258
|
+
params.noDefaultAlpn = true;
|
|
259
|
+
else
|
|
260
|
+
params.ohttp = true;
|
|
261
|
+
break;
|
|
262
|
+
case 3:
|
|
263
|
+
if (value.length !== 2)
|
|
264
|
+
return null;
|
|
265
|
+
params.port = ((value[0] ?? 0) << 8) | (value[1] ?? 0);
|
|
266
|
+
break;
|
|
267
|
+
case 4: {
|
|
268
|
+
let addresses = chunks(value, 4);
|
|
269
|
+
if (!addresses)
|
|
270
|
+
return null;
|
|
271
|
+
params.ipv4Hint = addresses.map((address) => Array.from(address).join("."));
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case 5:
|
|
275
|
+
if (value.length === 0)
|
|
276
|
+
return null;
|
|
277
|
+
params.ech = encodeBase64(value);
|
|
278
|
+
break;
|
|
279
|
+
case 6: {
|
|
280
|
+
let addresses = chunks(value, 16);
|
|
281
|
+
if (!addresses)
|
|
282
|
+
return null;
|
|
283
|
+
params.ipv6Hint = addresses.map(printIpv6);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
case 7:
|
|
287
|
+
if (value.length === 0)
|
|
288
|
+
return null;
|
|
289
|
+
params.dohPath = decodeOctets(value);
|
|
290
|
+
break;
|
|
291
|
+
default:
|
|
292
|
+
params.other = { ...params.other, [key]: decodeOctets(value) };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return params;
|
|
296
|
+
}
|
|
297
|
+
/** Escapes a value-list item so its commas and backslashes read back as part of it. */
|
|
298
|
+
function escapeItem(item) {
|
|
299
|
+
return item.replace(/[\\,]/g, "\\$&");
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Prints params in canonical presentation form, keys in ascending number order and values
|
|
303
|
+
* quoted wherever they are free text, so two spellings of one record print alike.
|
|
304
|
+
*
|
|
305
|
+
* @param params - The params to print.
|
|
306
|
+
* @param quote - Prints a character-string with its escapes.
|
|
307
|
+
* @returns The fields joined by spaces; empty for no params.
|
|
308
|
+
*/
|
|
309
|
+
export function printSvcParams(params, quote) {
|
|
310
|
+
let fields = [];
|
|
311
|
+
if (params.mandatory) {
|
|
312
|
+
let keys = params.mandatory.map((name) => [keyNumber(name) ?? 0xffff, name]);
|
|
313
|
+
let sorted = keys.sort(([a], [b]) => a - b).map(([, name]) => name);
|
|
314
|
+
fields.push([0, `mandatory=${sorted.join(",")}`]);
|
|
315
|
+
}
|
|
316
|
+
if (params.alpn)
|
|
317
|
+
fields.push([1, `alpn=${quote(params.alpn.map(escapeItem).join(","))}`]);
|
|
318
|
+
if (params.noDefaultAlpn)
|
|
319
|
+
fields.push([2, "no-default-alpn"]);
|
|
320
|
+
if (params.port !== undefined)
|
|
321
|
+
fields.push([3, `port=${params.port}`]);
|
|
322
|
+
if (params.ipv4Hint)
|
|
323
|
+
fields.push([4, `ipv4hint=${params.ipv4Hint.join(",")}`]);
|
|
324
|
+
if (params.ech !== undefined)
|
|
325
|
+
fields.push([5, `ech=${params.ech}`]);
|
|
326
|
+
if (params.ipv6Hint)
|
|
327
|
+
fields.push([6, `ipv6hint=${params.ipv6Hint.join(",")}`]);
|
|
328
|
+
if (params.dohPath !== undefined)
|
|
329
|
+
fields.push([7, `dohpath=${quote(params.dohPath)}`]);
|
|
330
|
+
if (params.ohttp)
|
|
331
|
+
fields.push([8, "ohttp"]);
|
|
332
|
+
for (let [key, value] of Object.entries(params.other ?? {}))
|
|
333
|
+
fields.push([Number(key), `key${key}=${quote(value)}`]);
|
|
334
|
+
return fields
|
|
335
|
+
.sort(([a], [b]) => a - b)
|
|
336
|
+
.map(([, field]) => field)
|
|
337
|
+
.join(" ");
|
|
338
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @copyright Sergio Xalambrí 2026
|
|
8
8
|
*/
|
|
9
9
|
/** The record types whose data is read into typed fields. */
|
|
10
|
-
export type TypedRecordType = "A" | "AAAA" | "CNAME" | "NS" | "PTR" | "DNAME" | "MX" | "TXT" | "CAA" | "SRV" | "SOA";
|
|
10
|
+
export type TypedRecordType = "A" | "AAAA" | "CNAME" | "NS" | "PTR" | "DNAME" | "MX" | "TXT" | "CAA" | "SRV" | "SOA" | "SVCB" | "HTTPS" | "TLSA" | "DS" | "CDS" | "DNSKEY" | "CDNSKEY";
|
|
11
11
|
/** A record type by mnemonic; the typed ones autocomplete, any other name is accepted. */
|
|
12
12
|
export type RecordType = TypedRecordType | (string & {});
|
|
13
13
|
/** A record class; `IN` is the internet, `CLASSnnn` spells one without a mnemonic. */
|
|
@@ -90,13 +90,102 @@ export interface SOAData {
|
|
|
90
90
|
/** Bounds how long a negative answer may be cached (RFC 2308). */
|
|
91
91
|
minimum: number;
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* The parameters of an SVCB or HTTPS record (RFC 9460), one optional field per key. A key
|
|
95
|
+
* that is absent was not published; `true` marks a key published without a value.
|
|
96
|
+
*/
|
|
97
|
+
export interface SvcParams {
|
|
98
|
+
/** Keys a client must understand to use the record, by presentation name (`alpn`, `key65000`). */
|
|
99
|
+
mandatory?: string[];
|
|
100
|
+
/** Protocol ids (`h2`, `h3`) in the publisher's order of preference. */
|
|
101
|
+
alpn?: string[];
|
|
102
|
+
/** The default ALPN (`http/1.1` for HTTPS) is unsupported; only `alpn` applies. */
|
|
103
|
+
noDefaultAlpn?: true;
|
|
104
|
+
port?: number;
|
|
105
|
+
/** IPv4 addresses a client may use before the target's A records resolve. */
|
|
106
|
+
ipv4Hint?: string[];
|
|
107
|
+
/** The ECHConfigList, base64-encoded as the presentation format writes it. */
|
|
108
|
+
ech?: string;
|
|
109
|
+
/** IPv6 addresses in RFC 5952 canonical form, as hints like `ipv4Hint`. */
|
|
110
|
+
ipv6Hint?: string[];
|
|
111
|
+
/** The DoH URI template's path (RFC 9461), relative to the target. */
|
|
112
|
+
dohPath?: string;
|
|
113
|
+
/** The service accepts Oblivious HTTP requests (RFC 9540). */
|
|
114
|
+
ohttp?: true;
|
|
115
|
+
/** Keys without a reading here, by key number, each value's octets read as UTF-8. */
|
|
116
|
+
other?: {
|
|
117
|
+
[key: number]: string;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** A service binding (RFC 9460): where and how to reach the service at the owner. */
|
|
121
|
+
export interface SVCBData {
|
|
122
|
+
type: "SVCB";
|
|
123
|
+
/** `0` is AliasMode, which only names `target`; any other value orders ServiceMode records, lowest first. */
|
|
124
|
+
priority: number;
|
|
125
|
+
/** The service's host; `"."` means the owner itself in ServiceMode and no service in AliasMode. */
|
|
126
|
+
target: string;
|
|
127
|
+
params: SvcParams;
|
|
128
|
+
}
|
|
129
|
+
/** The SVCB record for HTTPS origins (RFC 9460), published at the origin's own name. */
|
|
130
|
+
export interface HTTPSData extends Omit<SVCBData, "type"> {
|
|
131
|
+
type: "HTTPS";
|
|
132
|
+
}
|
|
133
|
+
/** Binds a TLS certificate or public key to a service, DANE (RFC 6698, RFC 7671). */
|
|
134
|
+
export interface TLSAData {
|
|
135
|
+
type: "TLSA";
|
|
136
|
+
/** `0` PKIX-TA, `1` PKIX-EE, `2` DANE-TA, `3` DANE-EE. */
|
|
137
|
+
usage: number;
|
|
138
|
+
/** `0` matches the whole certificate, `1` its SubjectPublicKeyInfo. */
|
|
139
|
+
selector: number;
|
|
140
|
+
/** `0` the exact bytes, `1` their SHA-256, `2` their SHA-512. */
|
|
141
|
+
matchingType: number;
|
|
142
|
+
/** Lowercase hex. */
|
|
143
|
+
certificateAssociation: string;
|
|
144
|
+
}
|
|
145
|
+
/** A digest of the child zone's key-signing key, published in the parent (RFC 4034). */
|
|
146
|
+
export interface DSData {
|
|
147
|
+
type: "DS";
|
|
148
|
+
/** The key tag of the DNSKEY the digest is of, for picking it out of the child's key set. */
|
|
149
|
+
keyTag: number;
|
|
150
|
+
/** The DNSSEC algorithm number; `13` is ECDSA P-256 with SHA-256. */
|
|
151
|
+
algorithm: number;
|
|
152
|
+
/** `1` SHA-1, `2` SHA-256, `4` SHA-384. */
|
|
153
|
+
digestType: number;
|
|
154
|
+
/** Lowercase hex. */
|
|
155
|
+
digest: string;
|
|
156
|
+
}
|
|
157
|
+
/** The child's DS record as it asks the parent to publish it (RFC 7344); `0 0 0 00` asks for removal. */
|
|
158
|
+
export interface CDSData extends Omit<DSData, "type"> {
|
|
159
|
+
type: "CDS";
|
|
160
|
+
}
|
|
161
|
+
/** A zone's public signing key (RFC 4034). */
|
|
162
|
+
export interface DNSKEYData {
|
|
163
|
+
type: "DNSKEY";
|
|
164
|
+
/** The flags as published, so the record prints back unchanged. */
|
|
165
|
+
flags: number;
|
|
166
|
+
/** Bit 7 of `flags`: the key signs the zone's records; DNSSEC ignores a key without it. */
|
|
167
|
+
zoneKey: boolean;
|
|
168
|
+
/** Bit 15 of `flags`: the key is a key-signing key, the one a DS record points at. */
|
|
169
|
+
secureEntryPoint: boolean;
|
|
170
|
+
/** Always `3` in a valid key. */
|
|
171
|
+
protocol: number;
|
|
172
|
+
algorithm: number;
|
|
173
|
+
/** Base64, as the presentation format writes it. */
|
|
174
|
+
publicKey: string;
|
|
175
|
+
/** The RFC 4034 Appendix B tag a DS or RRSIG names this key by. */
|
|
176
|
+
keyTag: number;
|
|
177
|
+
}
|
|
178
|
+
/** The child's DNSKEY as it asks the parent to derive a DS from it (RFC 7344). */
|
|
179
|
+
export interface CDNSKEYData extends Omit<DNSKEYData, "type"> {
|
|
180
|
+
type: "CDNSKEY";
|
|
181
|
+
}
|
|
93
182
|
/** Data of a type without a typed reading, kept as written. */
|
|
94
183
|
export interface UnknownData {
|
|
95
184
|
type: string;
|
|
96
185
|
data: string;
|
|
97
186
|
}
|
|
98
187
|
/** A record's type-specific fields, what `parseRecordData` reads out of RDATA. */
|
|
99
|
-
export type RecordData<Type extends RecordType> = Type extends "A" ? AData : Type extends "AAAA" ? AAAAData : Type extends "CNAME" ? CNAMEData : Type extends "NS" ? NSData : Type extends "PTR" ? PTRData : Type extends "DNAME" ? DNAMEData : Type extends "MX" ? MXData : Type extends "TXT" ? TXTData : Type extends "CAA" ? CAAData : Type extends "SRV" ? SRVData : Type extends "SOA" ? SOAData : UnknownData;
|
|
188
|
+
export type RecordData<Type extends RecordType> = Type extends "A" ? AData : Type extends "AAAA" ? AAAAData : Type extends "CNAME" ? CNAMEData : Type extends "NS" ? NSData : Type extends "PTR" ? PTRData : Type extends "DNAME" ? DNAMEData : Type extends "MX" ? MXData : Type extends "TXT" ? TXTData : Type extends "CAA" ? CAAData : Type extends "SRV" ? SRVData : Type extends "SOA" ? SOAData : Type extends "SVCB" ? SVCBData : Type extends "HTTPS" ? HTTPSData : Type extends "TLSA" ? TLSAData : Type extends "DS" ? DSData : Type extends "CDS" ? CDSData : Type extends "DNSKEY" ? DNSKEYData : Type extends "CDNSKEY" ? CDNSKEYData : UnknownData;
|
|
100
189
|
/** What every record read from a file carries beside its data. */
|
|
101
190
|
export interface RecordFields {
|
|
102
191
|
/** Absolute, lowercased, no trailing dot; the root is `"."`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/zone-file",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.25",
|
|
4
4
|
"description": "Read and write RFC 1035 DNS zone files, with the record data codec for every typed record",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@sdxc/result": "2026.10.13"
|
|
12
12
|
},
|
|
13
|
-
"gitHead": "
|
|
13
|
+
"gitHead": "1440b969c0a3e3808dfcee3f17d2ef43915f32a8",
|
|
14
14
|
"publishConfig": {
|
|
15
15
|
"access": "public"
|
|
16
16
|
},
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
20
20
|
"directory": "packages/zone-file"
|
|
21
21
|
},
|
|
22
|
-
"homepage": "https://sdxc.
|
|
22
|
+
"homepage": "https://sdxc.dev/api/zone-file",
|
|
23
23
|
"author": {
|
|
24
24
|
"name": "Sergio Xalambrí",
|
|
25
25
|
"url": "https://sergiodxa.com"
|