@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +356 -199
  3. package/dist/dns-rr.cjs +7050 -0
  4. package/dist/dns-rr.min.js +26 -0
  5. package/dist/dns-rr.min.js.map +7 -0
  6. package/index.js +12 -11
  7. package/lib/binary.js +108 -0
  8. package/lib/bind.js +96 -0
  9. package/lib/dns-query.js +67 -0
  10. package/lib/tinydns.js +128 -44
  11. package/lib/wire.js +629 -0
  12. package/package.json +28 -8
  13. package/rr/TEMPLATE.js +124 -0
  14. package/rr/a.js +8 -48
  15. package/rr/aaaa.js +13 -80
  16. package/rr/apl.js +123 -27
  17. package/rr/caa.js +27 -50
  18. package/rr/cert.js +60 -81
  19. package/rr/cname.js +8 -59
  20. package/rr/dhcid.js +20 -30
  21. package/rr/dname.js +10 -32
  22. package/rr/dnskey.js +38 -32
  23. package/rr/ds.js +59 -54
  24. package/rr/hinfo.js +21 -40
  25. package/rr/hip.js +88 -26
  26. package/rr/https.js +28 -40
  27. package/rr/ipseckey.js +97 -18
  28. package/rr/key.js +39 -29
  29. package/rr/kx.js +16 -27
  30. package/rr/loc.js +42 -12
  31. package/rr/mx.js +17 -48
  32. package/rr/naptr.js +58 -33
  33. package/rr/ns.js +8 -36
  34. package/rr/nsec.js +89 -18
  35. package/rr/nsec3.js +62 -26
  36. package/rr/nsec3param.js +56 -53
  37. package/rr/nxt.js +57 -18
  38. package/rr/openpgpkey.js +24 -30
  39. package/rr/ptr.js +7 -47
  40. package/rr/rp.js +17 -32
  41. package/rr/rrsig.js +108 -75
  42. package/rr/sig.js +70 -45
  43. package/rr/smimea.js +33 -33
  44. package/rr/soa.js +31 -49
  45. package/rr/spf.js +15 -18
  46. package/rr/srv.js +28 -51
  47. package/rr/sshfp.js +35 -48
  48. package/rr/svcb.js +27 -40
  49. package/rr/tlsa.js +34 -34
  50. package/rr/tsig.js +87 -24
  51. package/rr/txt.js +77 -68
  52. package/rr/uri.js +19 -29
  53. package/rr/wks.js +154 -22
  54. package/rr.js +290 -109
  55. 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.6.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": "node lib/readme.js",
15
- "clean": "rm -rf coverage node_modules package-lock.json",
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 --ignore-path .gitignore --check .",
21
- "prettier:fix": "npx prettier --ignore-path .gitignore --write .",
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.2.1",
55
- "globals": "^17.5.0"
74
+ "eslint": "^10.4.0",
75
+ "globals": "^17.6.0"
56
76
  },
57
77
  "prettier": {
58
78
  "printWidth": 110,