@nictool/dns-resource-record 1.6.1 → 1.8.0
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/CHANGELOG.md +47 -0
- package/README.md +356 -199
- package/dist/dns-rr.cjs +7050 -0
- package/dist/dns-rr.min.js +26 -0
- package/dist/dns-rr.min.js.map +7 -0
- package/index.js +12 -11
- package/lib/binary.js +108 -0
- package/lib/bind.js +96 -0
- package/lib/dns-query.js +67 -0
- package/lib/tinydns.js +128 -44
- package/lib/wire.js +629 -0
- package/package.json +28 -8
- package/rr/TEMPLATE.js +124 -0
- package/rr/a.js +8 -48
- package/rr/aaaa.js +13 -80
- package/rr/apl.js +123 -27
- package/rr/caa.js +27 -50
- package/rr/cert.js +60 -81
- package/rr/cname.js +8 -59
- package/rr/dhcid.js +20 -30
- package/rr/dname.js +10 -32
- package/rr/dnskey.js +38 -32
- package/rr/ds.js +59 -54
- package/rr/hinfo.js +21 -40
- package/rr/hip.js +88 -26
- package/rr/https.js +28 -40
- package/rr/ipseckey.js +97 -18
- package/rr/key.js +39 -29
- package/rr/kx.js +16 -27
- package/rr/loc.js +42 -12
- package/rr/mx.js +17 -48
- package/rr/naptr.js +58 -33
- package/rr/ns.js +8 -36
- package/rr/nsec.js +89 -18
- package/rr/nsec3.js +62 -26
- package/rr/nsec3param.js +56 -53
- package/rr/nxt.js +57 -18
- package/rr/openpgpkey.js +24 -30
- package/rr/ptr.js +7 -47
- package/rr/rp.js +17 -32
- package/rr/rrsig.js +108 -75
- package/rr/sig.js +70 -45
- package/rr/smimea.js +33 -33
- package/rr/soa.js +31 -49
- package/rr/spf.js +15 -18
- package/rr/srv.js +28 -51
- package/rr/sshfp.js +35 -48
- package/rr/svcb.js +27 -40
- package/rr/tlsa.js +34 -34
- package/rr/tsig.js +87 -24
- package/rr/txt.js +77 -68
- package/rr/uri.js +19 -29
- package/rr/wks.js +154 -22
- package/rr.js +290 -109
- package/lib/readme.js +0 -84
package/lib/wire.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { base64ToBytes, bytesToBase64, bytesToHex, hexToBytes } from './binary.js'
|
|
2
|
+
import { expandIPv6 } from './tinydns.js'
|
|
3
|
+
|
|
4
|
+
// ── Name encoding/decoding ────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export function encodeName(name) {
|
|
7
|
+
const labels = name.replace(/\.$/, '').split('.')
|
|
8
|
+
const parts = labels.map((l) => {
|
|
9
|
+
const b = Buffer.from(l)
|
|
10
|
+
return Buffer.concat([Buffer.from([b.length]), b])
|
|
11
|
+
})
|
|
12
|
+
return Buffer.concat([...parts, Buffer.from([0])])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a (possibly pointer-compressed) DNS name from a packet.
|
|
17
|
+
* Returns { bytes: Buffer (uncompressed wire name), end: offset after name }.
|
|
18
|
+
*
|
|
19
|
+
* Bounded to defend against malicious or truncated packets:
|
|
20
|
+
* - rejects pointer cycles via a visited-offset set
|
|
21
|
+
* - caps total pointer dereferences at 128
|
|
22
|
+
* - caps the decoded name at 255 bytes (RFC 1035 §3.1)
|
|
23
|
+
* - rejects truncated pointers, pointer targets past the packet end,
|
|
24
|
+
* truncated labels, and unterminated names
|
|
25
|
+
*/
|
|
26
|
+
export function readWireName(packet, offset) {
|
|
27
|
+
if (offset < 0 || offset >= packet.length) {
|
|
28
|
+
throw new Error('readWireName: offset out of bounds')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const labels = []
|
|
32
|
+
const visited = new Set()
|
|
33
|
+
let pos = offset
|
|
34
|
+
let end = -1
|
|
35
|
+
let nameLen = 0
|
|
36
|
+
let terminated = false
|
|
37
|
+
|
|
38
|
+
while (pos < packet.length) {
|
|
39
|
+
const len = packet[pos]
|
|
40
|
+
if (len === 0) {
|
|
41
|
+
if (end === -1) end = pos + 1
|
|
42
|
+
labels.push(Buffer.from([0]))
|
|
43
|
+
terminated = true
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
if ((len & 0xc0) === 0xc0) {
|
|
47
|
+
if (pos + 1 >= packet.length) throw new Error('readWireName: truncated pointer')
|
|
48
|
+
if (end === -1) end = pos + 2
|
|
49
|
+
if (visited.has(pos)) throw new Error('readWireName: pointer cycle detected')
|
|
50
|
+
visited.add(pos)
|
|
51
|
+
if (visited.size > 128) throw new Error('readWireName: pointer chain too long')
|
|
52
|
+
const target = ((len & 0x3f) << 8) | packet[pos + 1]
|
|
53
|
+
if (target >= packet.length) throw new Error('readWireName: pointer target out of bounds')
|
|
54
|
+
pos = target
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
// Top two bits not 00 → reserved (RFC 1035 §4.1.4 / RFC 6891)
|
|
58
|
+
if ((len & 0xc0) !== 0) throw new Error('readWireName: reserved label type')
|
|
59
|
+
if (len > 63) throw new Error('readWireName: label exceeds 63 bytes')
|
|
60
|
+
if (pos + 1 + len > packet.length) throw new Error('readWireName: truncated label')
|
|
61
|
+
const label = packet.slice(pos, pos + 1 + len)
|
|
62
|
+
labels.push(label)
|
|
63
|
+
// RFC 1035 §3.1: octets of the full domain name (labels + length octets +
|
|
64
|
+
// final zero) MUST NOT exceed 255 — count the root terminator too.
|
|
65
|
+
nameLen += 1 + len
|
|
66
|
+
if (nameLen + 1 > 255) throw new Error('readWireName: name exceeds 255 bytes')
|
|
67
|
+
pos += 1 + len
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!terminated) throw new Error('readWireName: unterminated name')
|
|
71
|
+
return { bytes: Buffer.concat(labels), end }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decompress rdata for types that embed domain names (RFC 1035 §3.3).
|
|
76
|
+
* For all other types, returns the raw rdata bytes unchanged.
|
|
77
|
+
*/
|
|
78
|
+
export function decompressRdata(packet, rdataOffset, rdlen, typeId) {
|
|
79
|
+
const raw = packet.slice(rdataOffset, rdataOffset + rdlen)
|
|
80
|
+
|
|
81
|
+
switch (typeId) {
|
|
82
|
+
// Single name: NS, CNAME, PTR, DNAME
|
|
83
|
+
case 2:
|
|
84
|
+
case 5:
|
|
85
|
+
case 12:
|
|
86
|
+
case 39: {
|
|
87
|
+
const { bytes } = readWireName(packet, rdataOffset)
|
|
88
|
+
return bytes
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// SOA: mname + rname + 5 × uint32
|
|
92
|
+
case 6: {
|
|
93
|
+
const { bytes: mname, end: e1 } = readWireName(packet, rdataOffset)
|
|
94
|
+
const { bytes: rname, end: e2 } = readWireName(packet, e1)
|
|
95
|
+
const tail = packet.slice(e2, rdataOffset + rdlen) // 20 bytes
|
|
96
|
+
return Buffer.concat([mname, rname, tail])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// preference(2) + name: MX, KX
|
|
100
|
+
case 15:
|
|
101
|
+
case 36: {
|
|
102
|
+
const pref = raw.slice(0, 2)
|
|
103
|
+
const { bytes } = readWireName(packet, rdataOffset + 2)
|
|
104
|
+
return Buffer.concat([pref, bytes])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// priority(2) + weight(2) + port(2) + name: SRV
|
|
108
|
+
case 33: {
|
|
109
|
+
const head = raw.slice(0, 6)
|
|
110
|
+
const { bytes } = readWireName(packet, rdataOffset + 6)
|
|
111
|
+
return Buffer.concat([head, bytes])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// mbox + txt (two names): RP
|
|
115
|
+
case 17: {
|
|
116
|
+
const { bytes: mbox, end: e1 } = readWireName(packet, rdataOffset)
|
|
117
|
+
const { bytes: txt } = readWireName(packet, e1)
|
|
118
|
+
return Buffer.concat([mbox, txt])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// order(2)+pref(2)+flags_str+svc_str+regexp_str+name: NAPTR
|
|
122
|
+
case 35: {
|
|
123
|
+
const head = raw.slice(0, 4)
|
|
124
|
+
let pos = rdataOffset + 4
|
|
125
|
+
const strings = []
|
|
126
|
+
for (let i = 0; i < 3; i++) {
|
|
127
|
+
const slen = packet[pos]
|
|
128
|
+
strings.push(packet.slice(pos, pos + 1 + slen))
|
|
129
|
+
pos += 1 + slen
|
|
130
|
+
}
|
|
131
|
+
const { bytes: name } = readWireName(packet, pos)
|
|
132
|
+
return Buffer.concat([head, ...strings, name])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
default:
|
|
136
|
+
return raw
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Query building and parsing ────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export function buildQuery(name, typeId, classId = 1) {
|
|
143
|
+
const nameBytes = encodeName(name)
|
|
144
|
+
const header = Buffer.alloc(12)
|
|
145
|
+
header.writeUInt16BE(Math.floor(Math.random() * 65536), 0)
|
|
146
|
+
header.writeUInt16BE(0x0100, 2) // RD=1
|
|
147
|
+
header.writeUInt16BE(1, 4) // QDCOUNT=1
|
|
148
|
+
header.writeUInt16BE(1, 10) // ARCOUNT=1 (OPT record)
|
|
149
|
+
const question = Buffer.alloc(nameBytes.length + 4)
|
|
150
|
+
nameBytes.copy(question)
|
|
151
|
+
question.writeUInt16BE(typeId, nameBytes.length)
|
|
152
|
+
question.writeUInt16BE(classId, nameBytes.length + 2)
|
|
153
|
+
// EDNS0 OPT record: root name, type=OPT(41), class=4096 (UDP payload), TTL=0, RDLEN=0
|
|
154
|
+
const opt = Buffer.alloc(11)
|
|
155
|
+
opt[0] = 0x00 // owner = root
|
|
156
|
+
opt.writeUInt16BE(41, 1) // type = OPT
|
|
157
|
+
opt.writeUInt16BE(4096, 3) // UDP payload size
|
|
158
|
+
opt.writeUInt32BE(0, 5) // extended RCODE and flags
|
|
159
|
+
opt.writeUInt16BE(0, 9) // RDLENGTH = 0
|
|
160
|
+
return Buffer.concat([header, question, opt])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function parseResponse(packet) {
|
|
164
|
+
const rcode = packet.readUInt16BE(2) & 0xf
|
|
165
|
+
const qdcount = packet.readUInt16BE(4)
|
|
166
|
+
const ancount = packet.readUInt16BE(6)
|
|
167
|
+
|
|
168
|
+
if (rcode !== 0) return { rcode, answers: [] }
|
|
169
|
+
|
|
170
|
+
let offset = 12
|
|
171
|
+
for (let i = 0; i < qdcount; i++) {
|
|
172
|
+
const { end } = readWireName(packet, offset)
|
|
173
|
+
offset = end + 4
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const answers = []
|
|
177
|
+
for (let i = 0; i < ancount; i++) {
|
|
178
|
+
const { bytes: ownerBytes, end: nameEnd } = readWireName(packet, offset)
|
|
179
|
+
offset = nameEnd
|
|
180
|
+
const typeId = packet.readUInt16BE(offset)
|
|
181
|
+
const cls = packet.readUInt16BE(offset + 2)
|
|
182
|
+
const rdlen = packet.readUInt16BE(offset + 8)
|
|
183
|
+
const rdataOffset = offset + 10
|
|
184
|
+
const rdataBytes = decompressRdata(packet, rdataOffset, rdlen, typeId)
|
|
185
|
+
offset += 10 + rdlen
|
|
186
|
+
answers.push({ ownerBytes, typeId, cls, rdataBytes })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { rcode: 0, answers }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Uncompressed domain name decoding ────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decode a length-prefixed (uncompressed) DNS name from a Uint8Array.
|
|
196
|
+
* Returns { fqdn: string, end: number } where end is the offset after the name.
|
|
197
|
+
*/
|
|
198
|
+
export function wireUnpackDomain(bytes, offset = 0) {
|
|
199
|
+
const labels = []
|
|
200
|
+
let pos = offset
|
|
201
|
+
while (pos < bytes.length) {
|
|
202
|
+
const len = bytes[pos]
|
|
203
|
+
if (len === 0) {
|
|
204
|
+
pos++
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
labels.push(new TextDecoder().decode(bytes.subarray(pos + 1, pos + 1 + len)))
|
|
208
|
+
pos += 1 + len
|
|
209
|
+
}
|
|
210
|
+
return { fqdn: labels.length ? labels.join('.') + '.' : '.', end: pos }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Pack a fully qualified domain name into wire format (Uint8Array).
|
|
215
|
+
* Domain labels are prefixed with their length; the name ends with a zero byte.
|
|
216
|
+
*/
|
|
217
|
+
export function wirePackDomain(fqdn) {
|
|
218
|
+
if (fqdn === '.') return new Uint8Array([0])
|
|
219
|
+
const enc = new TextEncoder()
|
|
220
|
+
const labels = fqdn
|
|
221
|
+
.split('.')
|
|
222
|
+
.filter((p) => p.length > 0)
|
|
223
|
+
.map((p) => {
|
|
224
|
+
const b = enc.encode(p)
|
|
225
|
+
if (b.length > 63) throw new Error(`DNS label exceeds 63 bytes: ${p}`)
|
|
226
|
+
return b
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const buf = new Uint8Array(labels.reduce((n, b) => n + b.length + 1, 1))
|
|
230
|
+
let offset = 0
|
|
231
|
+
for (const b of labels) {
|
|
232
|
+
buf[offset++] = b.length
|
|
233
|
+
buf.set(b, offset)
|
|
234
|
+
offset += b.length
|
|
235
|
+
}
|
|
236
|
+
buf[offset] = 0
|
|
237
|
+
return buf
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Pack a fully qualified domain name into wire format with RFC 1035 §4.1.4
|
|
242
|
+
* name compression. `dict` is a `Map<suffix, offset>` shared across all names
|
|
243
|
+
* emitted into a single outbound message; `currentOffset` is the absolute
|
|
244
|
+
* offset at which the caller intends to place the returned bytes.
|
|
245
|
+
*
|
|
246
|
+
* Walks labels from longest suffix to shortest: when a suffix already has an
|
|
247
|
+
* offset in `dict`, emits a 2-byte pointer and stops; otherwise emits the label
|
|
248
|
+
* verbatim and records the new suffix offset for later reuse.
|
|
249
|
+
*/
|
|
250
|
+
export function wirePackDomainCompressed(fqdn, dict, currentOffset) {
|
|
251
|
+
if (fqdn === '.') return new Uint8Array([0])
|
|
252
|
+
const enc = new TextEncoder()
|
|
253
|
+
const labels = fqdn
|
|
254
|
+
.split('.')
|
|
255
|
+
.filter((p) => p.length > 0)
|
|
256
|
+
.map((p) => {
|
|
257
|
+
const b = enc.encode(p)
|
|
258
|
+
if (b.length > 63) throw new Error(`DNS label exceeds 63 bytes: ${p}`)
|
|
259
|
+
return b
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// Pre-compute the absolute write offset of each label and the suffix it begins.
|
|
263
|
+
const suffixes = []
|
|
264
|
+
let prefixBytes = 0
|
|
265
|
+
for (let i = 0; i < labels.length; i++) {
|
|
266
|
+
suffixes.push({
|
|
267
|
+
key:
|
|
268
|
+
labels
|
|
269
|
+
.slice(i)
|
|
270
|
+
.map((b) => new TextDecoder().decode(b))
|
|
271
|
+
.join('.') + '.',
|
|
272
|
+
writeOffset: currentOffset + prefixBytes,
|
|
273
|
+
})
|
|
274
|
+
prefixBytes += 1 + labels[i].length
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Find the longest existing suffix in the dict; everything to its right gets
|
|
278
|
+
// replaced by a single pointer.
|
|
279
|
+
let pointerAt = labels.length
|
|
280
|
+
let pointerOffset = -1
|
|
281
|
+
for (let i = 0; i < suffixes.length; i++) {
|
|
282
|
+
const hit = dict.get(suffixes[i].key)
|
|
283
|
+
if (hit !== undefined && hit < 0x4000) {
|
|
284
|
+
pointerAt = i
|
|
285
|
+
pointerOffset = hit
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Compute output size and allocate.
|
|
291
|
+
let outSize = 0
|
|
292
|
+
for (let i = 0; i < pointerAt; i++) outSize += 1 + labels[i].length
|
|
293
|
+
outSize += pointerAt < labels.length ? 2 : 1 // pointer (2 bytes) or root terminator (1)
|
|
294
|
+
const buf = new Uint8Array(outSize)
|
|
295
|
+
|
|
296
|
+
let off = 0
|
|
297
|
+
for (let i = 0; i < pointerAt; i++) {
|
|
298
|
+
// Record this suffix for future reuse before writing past it.
|
|
299
|
+
if (suffixes[i].writeOffset < 0x4000 && !dict.has(suffixes[i].key)) {
|
|
300
|
+
dict.set(suffixes[i].key, suffixes[i].writeOffset)
|
|
301
|
+
}
|
|
302
|
+
buf[off++] = labels[i].length
|
|
303
|
+
buf.set(labels[i], off)
|
|
304
|
+
off += labels[i].length
|
|
305
|
+
}
|
|
306
|
+
if (pointerAt < labels.length) {
|
|
307
|
+
buf[off++] = 0xc0 | ((pointerOffset >> 8) & 0x3f)
|
|
308
|
+
buf[off] = pointerOffset & 0xff
|
|
309
|
+
} else {
|
|
310
|
+
buf[off] = 0
|
|
311
|
+
}
|
|
312
|
+
return buf
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get wire format rdata for an RR instance.
|
|
317
|
+
* Derived classes should override this with direct RFC 1035 wire encoding.
|
|
318
|
+
* Fallback uses toTinydns() as an intermediate (less efficient but works for all types).
|
|
319
|
+
*/
|
|
320
|
+
export function getWireRdata(rrInstance) {
|
|
321
|
+
const line = rrInstance.toTinydns()
|
|
322
|
+
if (!line.startsWith(':')) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`${rrInstance.get('type')}: getWireRdata() not implemented. Override in rr/${rrInstance.get('type').toLowerCase()}.js`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
// line: :fqdn:typeId:rdata:ttl:ts:loc\n
|
|
328
|
+
// Extract octal-encoded rdata and decode
|
|
329
|
+
const rdata = line.split(':')[3]
|
|
330
|
+
return rrInstance.octalToUint8Array(rdata ?? '')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Serialize an RR instance to DNS wire format (RFC 1035).
|
|
335
|
+
* Combines owner name, type, class, TTL, and rdata.
|
|
336
|
+
*/
|
|
337
|
+
export function toWire(rrInstance, RRClasses) {
|
|
338
|
+
const rdata = rrInstance.getWireRdata()
|
|
339
|
+
const owner = wirePackDomain(rrInstance.get('owner'))
|
|
340
|
+
const result = new Uint8Array(owner.length + 10 + rdata.length)
|
|
341
|
+
result.set(owner, 0)
|
|
342
|
+
const meta = new DataView(result.buffer, owner.length, 10)
|
|
343
|
+
meta.setUint16(0, rrInstance.getTypeId())
|
|
344
|
+
meta.setUint16(2, RRClasses[rrInstance.get('class')] ?? 1)
|
|
345
|
+
meta.setUint32(4, rrInstance.get('ttl'))
|
|
346
|
+
meta.setUint16(8, rdata.length)
|
|
347
|
+
result.set(rdata, owner.length + 10)
|
|
348
|
+
return result
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Deserialize wire format bytes into an RR instance.
|
|
353
|
+
* Static method wrapper for RR.fromWire().
|
|
354
|
+
*/
|
|
355
|
+
export function fromWireBytes(RRConstructor, wireBytes, wireUnpackFn) {
|
|
356
|
+
const instance = new RRConstructor(null)
|
|
357
|
+
const bytes = wireBytes instanceof Uint8Array ? wireBytes : new Uint8Array(wireBytes)
|
|
358
|
+
const { fqdn: owner, end } = wireUnpackFn(bytes, 0)
|
|
359
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset)
|
|
360
|
+
const classNum = view.getUint16(end + 2)
|
|
361
|
+
const RRClasses = { IN: 1, CS: 2, CH: 3, HS: 4, NONE: 254, ANY: 255 }
|
|
362
|
+
const cls = Object.keys(RRClasses).find((k) => RRClasses[k] === classNum) ?? 'IN'
|
|
363
|
+
const ttl = view.getUint32(end + 4)
|
|
364
|
+
const rdlen = view.getUint16(end + 8)
|
|
365
|
+
const rdata = bytes.slice(end + 10, end + 10 + rdlen)
|
|
366
|
+
return instance.fromWire({ owner, cls, ttl, rdata })
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Generic fromWire decoder driven by rdataFields type annotations.
|
|
371
|
+
* Supports u8, u16, u32, fqdn, hex, base64, str, qstr, charstr, qcharstr, charstrs, svcparams, ipv4, ipv6.
|
|
372
|
+
* See rr/TEMPLATE.js for descriptions and example type usage.
|
|
373
|
+
* Last typed field consumes all remaining bytes.
|
|
374
|
+
*/
|
|
375
|
+
export function fromWireGeneric(rrInstance, { owner, cls, ttl, rdata }) {
|
|
376
|
+
const result = { owner, ttl, class: cls, type: rrInstance.constructor.typeName }
|
|
377
|
+
const rdataFields = rrInstance.constructor.rdataFields ?? []
|
|
378
|
+
const dv = new DataView(rdata.buffer, rdata.byteOffset)
|
|
379
|
+
let pos = 0
|
|
380
|
+
|
|
381
|
+
for (let i = 0; i < rdataFields.length; i++) {
|
|
382
|
+
const entry = rdataFields[i]
|
|
383
|
+
const fieldName = Array.isArray(entry) ? entry[0] : entry
|
|
384
|
+
const fieldType = Array.isArray(entry) ? entry[1] : null
|
|
385
|
+
|
|
386
|
+
switch (fieldType) {
|
|
387
|
+
case 'u8':
|
|
388
|
+
result[fieldName] = rdata[pos++]
|
|
389
|
+
break
|
|
390
|
+
case 'u16':
|
|
391
|
+
result[fieldName] = dv.getUint16(pos)
|
|
392
|
+
pos += 2
|
|
393
|
+
break
|
|
394
|
+
case 'certtype': {
|
|
395
|
+
const certTypeNum = dv.getUint16(pos)
|
|
396
|
+
const reverse = rrInstance.constructor.CERT_TYPES_REVERSE
|
|
397
|
+
result[fieldName] = reverse?.[certTypeNum] ?? certTypeNum
|
|
398
|
+
pos += 2
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
case 'u32':
|
|
402
|
+
result[fieldName] = dv.getUint32(pos)
|
|
403
|
+
pos += 4
|
|
404
|
+
break
|
|
405
|
+
case 'fqdn': {
|
|
406
|
+
const { fqdn, end } = wireUnpackDomain(rdata, pos)
|
|
407
|
+
result[fieldName] = fqdn
|
|
408
|
+
pos = end
|
|
409
|
+
break
|
|
410
|
+
}
|
|
411
|
+
case 'hex':
|
|
412
|
+
result[fieldName] = bytesToHex(rdata.subarray(pos)).toUpperCase()
|
|
413
|
+
pos = rdata.length
|
|
414
|
+
break
|
|
415
|
+
case 'base64':
|
|
416
|
+
result[fieldName] = bytesToBase64(rdata.subarray(pos))
|
|
417
|
+
pos = rdata.length
|
|
418
|
+
break
|
|
419
|
+
case 'str':
|
|
420
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos))
|
|
421
|
+
pos = rdata.length
|
|
422
|
+
break
|
|
423
|
+
case 'qstr':
|
|
424
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos))
|
|
425
|
+
pos = rdata.length
|
|
426
|
+
break
|
|
427
|
+
case 'charstr': {
|
|
428
|
+
const strLen = rdata[pos++]
|
|
429
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos, pos + strLen))
|
|
430
|
+
pos += strLen
|
|
431
|
+
break
|
|
432
|
+
}
|
|
433
|
+
case 'qcharstr': {
|
|
434
|
+
const strLen = rdata[pos++]
|
|
435
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos, pos + strLen))
|
|
436
|
+
pos += strLen
|
|
437
|
+
break
|
|
438
|
+
}
|
|
439
|
+
case 'charstrs': {
|
|
440
|
+
// RFC 1035 §3.3.14: each <character-string> on the wire is len-prefixed.
|
|
441
|
+
// Preserve multi-string boundaries by returning an array when >1.
|
|
442
|
+
const parts = []
|
|
443
|
+
while (pos < rdata.length) {
|
|
444
|
+
const strLen = rdata[pos++]
|
|
445
|
+
if (pos + strLen > rdata.length) {
|
|
446
|
+
throw new Error('fromWireGeneric: truncated character-string in rdata')
|
|
447
|
+
}
|
|
448
|
+
parts.push(new TextDecoder().decode(rdata.subarray(pos, pos + strLen)))
|
|
449
|
+
pos += strLen
|
|
450
|
+
}
|
|
451
|
+
result[fieldName] = parts.length > 1 ? parts : (parts[0] ?? '')
|
|
452
|
+
break
|
|
453
|
+
}
|
|
454
|
+
case 'svcparams':
|
|
455
|
+
result[fieldName] = svcParamsFromWire(rdata.subarray(pos))
|
|
456
|
+
pos = rdata.length
|
|
457
|
+
break
|
|
458
|
+
case 'ipv4':
|
|
459
|
+
result[fieldName] = [...rdata.subarray(pos, pos + 4)].join('.')
|
|
460
|
+
pos += 4
|
|
461
|
+
break
|
|
462
|
+
case 'ipv6': {
|
|
463
|
+
const parts = []
|
|
464
|
+
for (let j = 0; j < 16; j += 2) {
|
|
465
|
+
parts.push(
|
|
466
|
+
dv
|
|
467
|
+
.getUint16(pos + j)
|
|
468
|
+
.toString(16)
|
|
469
|
+
.padStart(4, '0'),
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
result[fieldName] = parts.join(':')
|
|
473
|
+
pos += 16
|
|
474
|
+
break
|
|
475
|
+
}
|
|
476
|
+
default:
|
|
477
|
+
result[fieldName] = rdata[pos++]
|
|
478
|
+
break
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return new rrInstance.constructor(result)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── SVCB/HTTPS parameter encoding (RFC 9460) ─────────────────────────────────
|
|
486
|
+
|
|
487
|
+
const SVCPARAM_KEYS = {
|
|
488
|
+
mandatory: 0,
|
|
489
|
+
alpn: 1,
|
|
490
|
+
'no-default-alpn': 2,
|
|
491
|
+
port: 3,
|
|
492
|
+
ipv4hint: 4,
|
|
493
|
+
ech: 5,
|
|
494
|
+
ipv6hint: 6,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Encode SVCB/HTTPS params string (e.g. 'alpn="h2,h3" port="8443"') to wire bytes.
|
|
499
|
+
*/
|
|
500
|
+
export function svcParamsToWire(paramsStr) {
|
|
501
|
+
if (!paramsStr || !paramsStr.trim()) return new Uint8Array(0)
|
|
502
|
+
|
|
503
|
+
const parts = []
|
|
504
|
+
// Match: key, key=unquoted, or key="quoted" tokens
|
|
505
|
+
const re = /([^\s=]+)(?:=(?:"([^"]*)"|(\S*)))?(?=\s|$)/g
|
|
506
|
+
let m
|
|
507
|
+
while ((m = re.exec(paramsStr.trim())) !== null) {
|
|
508
|
+
const key = m[1].toLowerCase()
|
|
509
|
+
const val = m[2] ?? m[3] ?? ''
|
|
510
|
+
const keyId = SVCPARAM_KEYS[key]
|
|
511
|
+
if (keyId === undefined) continue
|
|
512
|
+
|
|
513
|
+
let valBytes
|
|
514
|
+
if (keyId === 1) {
|
|
515
|
+
// alpn: comma-separated list, each entry length-prefixed
|
|
516
|
+
const enc = new TextEncoder()
|
|
517
|
+
const entries = val.split(',').map((e) => enc.encode(e.trim()))
|
|
518
|
+
const total = entries.reduce((s, e) => s + 1 + e.length, 0)
|
|
519
|
+
valBytes = new Uint8Array(total)
|
|
520
|
+
let p = 0
|
|
521
|
+
for (const e of entries) {
|
|
522
|
+
valBytes[p++] = e.length
|
|
523
|
+
valBytes.set(e, p)
|
|
524
|
+
p += e.length
|
|
525
|
+
}
|
|
526
|
+
} else if (keyId === 2) {
|
|
527
|
+
// no-default-alpn: no value
|
|
528
|
+
valBytes = new Uint8Array(0)
|
|
529
|
+
} else if (keyId === 3) {
|
|
530
|
+
// port: uint16
|
|
531
|
+
valBytes = new Uint8Array(2)
|
|
532
|
+
new DataView(valBytes.buffer).setUint16(0, parseInt(val, 10))
|
|
533
|
+
} else if (keyId === 4) {
|
|
534
|
+
// ipv4hint: one or more IPv4 addresses
|
|
535
|
+
const addrs = val.split(',').map((a) => a.trim().split('.').map(Number))
|
|
536
|
+
valBytes = new Uint8Array(addrs.length * 4)
|
|
537
|
+
addrs.forEach((addr, i) => addr.forEach((b, j) => (valBytes[i * 4 + j] = b)))
|
|
538
|
+
} else if (keyId === 5) {
|
|
539
|
+
// ech: base64-encoded ECH config
|
|
540
|
+
valBytes = base64ToBytes(val)
|
|
541
|
+
} else if (keyId === 6) {
|
|
542
|
+
// ipv6hint: one or more IPv6 addresses (expand compressed form before encoding)
|
|
543
|
+
const addrs = val.split(',').map((a) => hexToBytes(expandIPv6(a.trim(), '')))
|
|
544
|
+
valBytes = new Uint8Array(addrs.length * 16)
|
|
545
|
+
addrs.forEach((addr, i) => valBytes.set(addr, i * 16))
|
|
546
|
+
} else {
|
|
547
|
+
valBytes = new TextEncoder().encode(val)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const param = new Uint8Array(4 + valBytes.length)
|
|
551
|
+
new DataView(param.buffer).setUint16(0, keyId)
|
|
552
|
+
new DataView(param.buffer).setUint16(2, valBytes.length)
|
|
553
|
+
param.set(valBytes, 4)
|
|
554
|
+
parts.push(param)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Sort by key ID (SvcParams MUST be in ascending order per RFC 9460)
|
|
558
|
+
parts.sort((a, b) => new DataView(a.buffer).getUint16(0) - new DataView(b.buffer).getUint16(0))
|
|
559
|
+
|
|
560
|
+
const total = parts.reduce((s, p) => s + p.length, 0)
|
|
561
|
+
const result = new Uint8Array(total)
|
|
562
|
+
let pos = 0
|
|
563
|
+
for (const p of parts) {
|
|
564
|
+
result.set(p, pos)
|
|
565
|
+
pos += p.length
|
|
566
|
+
}
|
|
567
|
+
return result
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const SVCPARAM_KEY_NAMES = {
|
|
571
|
+
0: 'mandatory',
|
|
572
|
+
1: 'alpn',
|
|
573
|
+
2: 'no-default-alpn',
|
|
574
|
+
3: 'port',
|
|
575
|
+
4: 'ipv4hint',
|
|
576
|
+
5: 'ech',
|
|
577
|
+
6: 'ipv6hint',
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Decode SVCB/HTTPS wire params bytes back to a human-readable params string.
|
|
582
|
+
*/
|
|
583
|
+
export function svcParamsFromWire(bytes) {
|
|
584
|
+
if (!bytes || bytes.length === 0) return ''
|
|
585
|
+
const parts = []
|
|
586
|
+
let pos = 0
|
|
587
|
+
while (pos + 4 <= bytes.length) {
|
|
588
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset + pos)
|
|
589
|
+
const keyId = dv.getUint16(0)
|
|
590
|
+
const valLen = dv.getUint16(2)
|
|
591
|
+
const val = bytes.subarray(pos + 4, pos + 4 + valLen)
|
|
592
|
+
pos += 4 + valLen
|
|
593
|
+
|
|
594
|
+
const keyName = SVCPARAM_KEY_NAMES[keyId] ?? `key${keyId}`
|
|
595
|
+
if (keyId === 1) {
|
|
596
|
+
// alpn: comma-separated length-prefixed entries
|
|
597
|
+
const entries = []
|
|
598
|
+
let p = 0
|
|
599
|
+
while (p < val.length) {
|
|
600
|
+
const len = val[p++]
|
|
601
|
+
entries.push(new TextDecoder().decode(val.subarray(p, p + len)))
|
|
602
|
+
p += len
|
|
603
|
+
}
|
|
604
|
+
parts.push(`alpn="${entries.join(',')}"`)
|
|
605
|
+
} else if (keyId === 2) {
|
|
606
|
+
parts.push('no-default-alpn')
|
|
607
|
+
} else if (keyId === 3) {
|
|
608
|
+
parts.push(`port=${new DataView(val.buffer, val.byteOffset).getUint16(0)}`)
|
|
609
|
+
} else if (keyId === 4) {
|
|
610
|
+
const addrs = []
|
|
611
|
+
for (let i = 0; i < val.length; i += 4) addrs.push([...val.subarray(i, i + 4)].join('.'))
|
|
612
|
+
parts.push(`ipv4hint=${addrs.join(',')}`)
|
|
613
|
+
} else if (keyId === 5) {
|
|
614
|
+
parts.push(`ech=${btoa([...val].map((b) => String.fromCharCode(b)).join(''))}`)
|
|
615
|
+
} else if (keyId === 6) {
|
|
616
|
+
const addrs = []
|
|
617
|
+
for (let i = 0; i < val.length; i += 16) {
|
|
618
|
+
const dv16 = new DataView(val.buffer, val.byteOffset + i)
|
|
619
|
+
const groups = []
|
|
620
|
+
for (let j = 0; j < 16; j += 2) groups.push(dv16.getUint16(j).toString(16).padStart(4, '0'))
|
|
621
|
+
addrs.push(groups.join(':'))
|
|
622
|
+
}
|
|
623
|
+
parts.push(`ipv6hint=${addrs.join(',')}`)
|
|
624
|
+
} else {
|
|
625
|
+
parts.push(`${keyName}=${[...val].map((b) => b.toString(16).padStart(2, '0')).join('')}`)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return parts.join(' ')
|
|
629
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nictool/dns-resource-record",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "DNS Resource Records",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"browser": "./dist/dns-rr.min.js",
|
|
9
|
+
"import": "./index.js",
|
|
10
|
+
"require": "./dist/dns-rr.cjs",
|
|
11
|
+
"default": "./index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
6
14
|
"files": [
|
|
15
|
+
"dist",
|
|
7
16
|
"lib",
|
|
8
17
|
"rr",
|
|
9
18
|
"CHANGELOG.md",
|
|
@@ -11,14 +20,16 @@
|
|
|
11
20
|
],
|
|
12
21
|
"type": "module",
|
|
13
22
|
"scripts": {
|
|
14
|
-
"build": "
|
|
15
|
-
"
|
|
23
|
+
"build": "npm run build:browser && npm run build:cjs",
|
|
24
|
+
"build:browser": "npx rollup -i index.js -o dist/dns-rr.js -f es && esbuild dist/dns-rr.js --minify --sourcemap --outfile=dist/dns-rr.min.js && rm dist/dns-rr.js",
|
|
25
|
+
"build:cjs": "npx rollup -i index.js -o dist/dns-rr.cjs -f cjs --exports named",
|
|
26
|
+
"clean": "rm -rf coverage dist node_modules package-lock.json",
|
|
16
27
|
"format:check": "npm run prettier; npm run lint",
|
|
17
28
|
"format": "npm run prettier -- --write && npm run lint:fix",
|
|
18
|
-
"lint": "npx eslint **/*.js",
|
|
29
|
+
"lint": "npx eslint **/*.js --no-warn-ignored",
|
|
19
30
|
"lint:fix": "npm run lint -- --fix",
|
|
20
|
-
"prettier": "npx prettier --
|
|
21
|
-
"prettier:fix": "npx prettier --
|
|
31
|
+
"prettier": "npx prettier --check .",
|
|
32
|
+
"prettier:fix": "npx prettier --write .",
|
|
22
33
|
"test": "node --test",
|
|
23
34
|
"test:coverage": "node --test --experimental-test-coverage",
|
|
24
35
|
"test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info",
|
|
@@ -49,10 +60,19 @@
|
|
|
49
60
|
"url": "https://github.com/NicTool/dns-resource-record/issues"
|
|
50
61
|
},
|
|
51
62
|
"homepage": "https://github.com/NicTool/dns-resource-record#readme",
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=20"
|
|
65
|
+
},
|
|
66
|
+
"browserslist": [
|
|
67
|
+
"chrome >= 74",
|
|
68
|
+
"firefox >= 90",
|
|
69
|
+
"safari >= 14.1",
|
|
70
|
+
"edge >= 79"
|
|
71
|
+
],
|
|
52
72
|
"devDependencies": {
|
|
53
73
|
"@eslint/js": "^10.0.1",
|
|
54
|
-
"eslint": "^10.
|
|
55
|
-
"globals": "^17.
|
|
74
|
+
"eslint": "^10.4.0",
|
|
75
|
+
"globals": "^17.6.0"
|
|
56
76
|
},
|
|
57
77
|
"prettier": {
|
|
58
78
|
"printWidth": 110,
|