@nictool/dns-resource-record 1.6.1 → 1.7.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 +33 -0
- package/README.md +323 -199
- 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 +94 -0
- package/lib/dns-query.js +67 -0
- package/lib/tinydns.js +128 -44
- package/lib/wire.js +519 -0
- package/package.json +23 -6
- 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 +58 -87
- package/rr/cname.js +7 -63
- package/rr/dhcid.js +20 -30
- package/rr/dname.js +9 -36
- package/rr/dnskey.js +38 -32
- package/rr/ds.js +43 -57
- 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 +16 -51
- 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 +80 -85
- package/rr/sig.js +70 -45
- package/rr/smimea.js +33 -33
- package/rr/soa.js +31 -49
- package/rr/spf.js +10 -17
- package/rr/srv.js +22 -62
- package/rr/sshfp.js +25 -53
- package/rr/svcb.js +27 -40
- package/rr/tlsa.js +34 -34
- package/rr/tsig.js +87 -24
- package/rr/txt.js +40 -59
- 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,519 @@
|
|
|
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
|
+
export function readWireName(packet, offset) {
|
|
20
|
+
const labels = []
|
|
21
|
+
let pos = offset
|
|
22
|
+
let end = -1
|
|
23
|
+
|
|
24
|
+
while (pos < packet.length) {
|
|
25
|
+
const len = packet[pos]
|
|
26
|
+
if (len === 0) {
|
|
27
|
+
if (end === -1) end = pos + 1
|
|
28
|
+
labels.push(Buffer.from([0]))
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
if ((len & 0xc0) === 0xc0) {
|
|
32
|
+
if (end === -1) end = pos + 2
|
|
33
|
+
pos = ((len & 0x3f) << 8) | packet[pos + 1]
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
const label = packet.slice(pos, pos + 1 + len)
|
|
37
|
+
labels.push(label)
|
|
38
|
+
pos += 1 + len
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { bytes: Buffer.concat(labels), end }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decompress rdata for types that embed domain names (RFC 1035 §3.3).
|
|
46
|
+
* For all other types, returns the raw rdata bytes unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export function decompressRdata(packet, rdataOffset, rdlen, typeId) {
|
|
49
|
+
const raw = packet.slice(rdataOffset, rdataOffset + rdlen)
|
|
50
|
+
|
|
51
|
+
switch (typeId) {
|
|
52
|
+
// Single name: NS, CNAME, PTR, DNAME
|
|
53
|
+
case 2:
|
|
54
|
+
case 5:
|
|
55
|
+
case 12:
|
|
56
|
+
case 39: {
|
|
57
|
+
const { bytes } = readWireName(packet, rdataOffset)
|
|
58
|
+
return bytes
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// SOA: mname + rname + 5 × uint32
|
|
62
|
+
case 6: {
|
|
63
|
+
const { bytes: mname, end: e1 } = readWireName(packet, rdataOffset)
|
|
64
|
+
const { bytes: rname, end: e2 } = readWireName(packet, e1)
|
|
65
|
+
const tail = packet.slice(e2, rdataOffset + rdlen) // 20 bytes
|
|
66
|
+
return Buffer.concat([mname, rname, tail])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// preference(2) + name: MX, KX
|
|
70
|
+
case 15:
|
|
71
|
+
case 36: {
|
|
72
|
+
const pref = raw.slice(0, 2)
|
|
73
|
+
const { bytes } = readWireName(packet, rdataOffset + 2)
|
|
74
|
+
return Buffer.concat([pref, bytes])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// priority(2) + weight(2) + port(2) + name: SRV
|
|
78
|
+
case 33: {
|
|
79
|
+
const head = raw.slice(0, 6)
|
|
80
|
+
const { bytes } = readWireName(packet, rdataOffset + 6)
|
|
81
|
+
return Buffer.concat([head, bytes])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// mbox + txt (two names): RP
|
|
85
|
+
case 17: {
|
|
86
|
+
const { bytes: mbox, end: e1 } = readWireName(packet, rdataOffset)
|
|
87
|
+
const { bytes: txt } = readWireName(packet, e1)
|
|
88
|
+
return Buffer.concat([mbox, txt])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// order(2)+pref(2)+flags_str+svc_str+regexp_str+name: NAPTR
|
|
92
|
+
case 35: {
|
|
93
|
+
const head = raw.slice(0, 4)
|
|
94
|
+
let pos = rdataOffset + 4
|
|
95
|
+
const strings = []
|
|
96
|
+
for (let i = 0; i < 3; i++) {
|
|
97
|
+
const slen = packet[pos]
|
|
98
|
+
strings.push(packet.slice(pos, pos + 1 + slen))
|
|
99
|
+
pos += 1 + slen
|
|
100
|
+
}
|
|
101
|
+
const { bytes: name } = readWireName(packet, pos)
|
|
102
|
+
return Buffer.concat([head, ...strings, name])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
return raw
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Query building and parsing ────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function buildQuery(name, typeId, classId = 1) {
|
|
113
|
+
const nameBytes = encodeName(name)
|
|
114
|
+
const header = Buffer.alloc(12)
|
|
115
|
+
header.writeUInt16BE(Math.floor(Math.random() * 65536), 0)
|
|
116
|
+
header.writeUInt16BE(0x0100, 2) // RD=1
|
|
117
|
+
header.writeUInt16BE(1, 4) // QDCOUNT=1
|
|
118
|
+
header.writeUInt16BE(1, 10) // ARCOUNT=1 (OPT record)
|
|
119
|
+
const question = Buffer.alloc(nameBytes.length + 4)
|
|
120
|
+
nameBytes.copy(question)
|
|
121
|
+
question.writeUInt16BE(typeId, nameBytes.length)
|
|
122
|
+
question.writeUInt16BE(classId, nameBytes.length + 2)
|
|
123
|
+
// EDNS0 OPT record: root name, type=OPT(41), class=4096 (UDP payload), TTL=0, RDLEN=0
|
|
124
|
+
const opt = Buffer.alloc(11)
|
|
125
|
+
opt[0] = 0x00 // owner = root
|
|
126
|
+
opt.writeUInt16BE(41, 1) // type = OPT
|
|
127
|
+
opt.writeUInt16BE(4096, 3) // UDP payload size
|
|
128
|
+
opt.writeUInt32BE(0, 5) // extended RCODE and flags
|
|
129
|
+
opt.writeUInt16BE(0, 9) // RDLENGTH = 0
|
|
130
|
+
return Buffer.concat([header, question, opt])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseResponse(packet) {
|
|
134
|
+
const rcode = packet.readUInt16BE(2) & 0xf
|
|
135
|
+
const qdcount = packet.readUInt16BE(4)
|
|
136
|
+
const ancount = packet.readUInt16BE(6)
|
|
137
|
+
|
|
138
|
+
if (rcode !== 0) return { rcode, answers: [] }
|
|
139
|
+
|
|
140
|
+
let offset = 12
|
|
141
|
+
for (let i = 0; i < qdcount; i++) {
|
|
142
|
+
const { end } = readWireName(packet, offset)
|
|
143
|
+
offset = end + 4
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const answers = []
|
|
147
|
+
for (let i = 0; i < ancount; i++) {
|
|
148
|
+
const { bytes: ownerBytes, end: nameEnd } = readWireName(packet, offset)
|
|
149
|
+
offset = nameEnd
|
|
150
|
+
const typeId = packet.readUInt16BE(offset)
|
|
151
|
+
const cls = packet.readUInt16BE(offset + 2)
|
|
152
|
+
const rdlen = packet.readUInt16BE(offset + 8)
|
|
153
|
+
const rdataOffset = offset + 10
|
|
154
|
+
const rdataBytes = decompressRdata(packet, rdataOffset, rdlen, typeId)
|
|
155
|
+
offset += 10 + rdlen
|
|
156
|
+
answers.push({ ownerBytes, typeId, cls, rdataBytes })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { rcode: 0, answers }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Uncompressed domain name decoding ────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Decode a length-prefixed (uncompressed) DNS name from a Uint8Array.
|
|
166
|
+
* Returns { fqdn: string, end: number } where end is the offset after the name.
|
|
167
|
+
*/
|
|
168
|
+
export function wireUnpackDomain(bytes, offset = 0) {
|
|
169
|
+
const labels = []
|
|
170
|
+
let pos = offset
|
|
171
|
+
while (pos < bytes.length) {
|
|
172
|
+
const len = bytes[pos]
|
|
173
|
+
if (len === 0) {
|
|
174
|
+
pos++
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
labels.push(new TextDecoder().decode(bytes.subarray(pos + 1, pos + 1 + len)))
|
|
178
|
+
pos += 1 + len
|
|
179
|
+
}
|
|
180
|
+
return { fqdn: labels.length ? labels.join('.') + '.' : '.', end: pos }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Pack a fully qualified domain name into wire format (Uint8Array).
|
|
185
|
+
* Domain labels are prefixed with their length; the name ends with a zero byte.
|
|
186
|
+
*/
|
|
187
|
+
export function wirePackDomain(fqdn) {
|
|
188
|
+
if (fqdn === '.') return new Uint8Array([0])
|
|
189
|
+
const enc = new TextEncoder()
|
|
190
|
+
const labels = fqdn
|
|
191
|
+
.split('.')
|
|
192
|
+
.filter((p) => p.length > 0)
|
|
193
|
+
.map((p) => {
|
|
194
|
+
const b = enc.encode(p)
|
|
195
|
+
if (b.length > 63) throw new Error(`DNS label exceeds 63 bytes: ${p}`)
|
|
196
|
+
return b
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const buf = new Uint8Array(labels.reduce((n, b) => n + b.length + 1, 1))
|
|
200
|
+
let offset = 0
|
|
201
|
+
for (const b of labels) {
|
|
202
|
+
buf[offset++] = b.length
|
|
203
|
+
buf.set(b, offset)
|
|
204
|
+
offset += b.length
|
|
205
|
+
}
|
|
206
|
+
buf[offset] = 0
|
|
207
|
+
return buf
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get wire format rdata for an RR instance.
|
|
212
|
+
* Derived classes should override this with direct RFC 1035 wire encoding.
|
|
213
|
+
* Fallback uses toTinydns() as an intermediate (less efficient but works for all types).
|
|
214
|
+
*/
|
|
215
|
+
export function getWireRdata(rrInstance) {
|
|
216
|
+
const line = rrInstance.toTinydns()
|
|
217
|
+
if (!line.startsWith(':')) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`${rrInstance.get('type')}: getWireRdata() not implemented. Override in rr/${rrInstance.get('type').toLowerCase()}.js`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
// line: :fqdn:typeId:rdata:ttl:ts:loc\n
|
|
223
|
+
// Extract octal-encoded rdata and decode
|
|
224
|
+
const rdata = line.split(':')[3]
|
|
225
|
+
return rrInstance.octalToUint8Array(rdata ?? '')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Serialize an RR instance to DNS wire format (RFC 1035).
|
|
230
|
+
* Combines owner name, type, class, TTL, and rdata.
|
|
231
|
+
*/
|
|
232
|
+
export function toWire(rrInstance, RRClasses) {
|
|
233
|
+
const rdata = rrInstance.getWireRdata()
|
|
234
|
+
const owner = wirePackDomain(rrInstance.get('owner'))
|
|
235
|
+
const result = new Uint8Array(owner.length + 10 + rdata.length)
|
|
236
|
+
result.set(owner, 0)
|
|
237
|
+
const meta = new DataView(result.buffer, owner.length, 10)
|
|
238
|
+
meta.setUint16(0, rrInstance.getTypeId())
|
|
239
|
+
meta.setUint16(2, RRClasses[rrInstance.get('class')] ?? 1)
|
|
240
|
+
meta.setUint32(4, rrInstance.get('ttl'))
|
|
241
|
+
meta.setUint16(8, rdata.length)
|
|
242
|
+
result.set(rdata, owner.length + 10)
|
|
243
|
+
return result
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Deserialize wire format bytes into an RR instance.
|
|
248
|
+
* Static method wrapper for RR.fromWire().
|
|
249
|
+
*/
|
|
250
|
+
export function fromWireBytes(RRConstructor, wireBytes, wireUnpackFn) {
|
|
251
|
+
const instance = new RRConstructor(null)
|
|
252
|
+
const bytes = wireBytes instanceof Uint8Array ? wireBytes : new Uint8Array(wireBytes)
|
|
253
|
+
const { fqdn: owner, end } = wireUnpackFn(bytes, 0)
|
|
254
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset)
|
|
255
|
+
const classNum = view.getUint16(end + 2)
|
|
256
|
+
const RRClasses = { IN: 1, CS: 2, CH: 3, HS: 4, NONE: 254, ANY: 255 }
|
|
257
|
+
const cls = Object.keys(RRClasses).find((k) => RRClasses[k] === classNum) ?? 'IN'
|
|
258
|
+
const ttl = view.getUint32(end + 4)
|
|
259
|
+
const rdlen = view.getUint16(end + 8)
|
|
260
|
+
const rdata = bytes.slice(end + 10, end + 10 + rdlen)
|
|
261
|
+
return instance.fromWire({ owner, cls, ttl, rdata })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Generic fromWire decoder driven by rdataFields type annotations.
|
|
266
|
+
* Supports u8, u16, u32, fqdn, hex, base64, str, qstr, charstr, qcharstr, charstrs, svcparams, ipv4, ipv6.
|
|
267
|
+
* See rr/TEMPLATE.js for descriptions and example type usage.
|
|
268
|
+
* Last typed field consumes all remaining bytes.
|
|
269
|
+
*/
|
|
270
|
+
export function fromWireGeneric(rrInstance, { owner, cls, ttl, rdata }) {
|
|
271
|
+
const result = { owner, ttl, class: cls, type: rrInstance.constructor.typeName }
|
|
272
|
+
const rdataFields = rrInstance.constructor.rdataFields ?? []
|
|
273
|
+
const dv = new DataView(rdata.buffer, rdata.byteOffset)
|
|
274
|
+
let pos = 0
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < rdataFields.length; i++) {
|
|
277
|
+
const entry = rdataFields[i]
|
|
278
|
+
const fieldName = Array.isArray(entry) ? entry[0] : entry
|
|
279
|
+
const fieldType = Array.isArray(entry) ? entry[1] : null
|
|
280
|
+
|
|
281
|
+
switch (fieldType) {
|
|
282
|
+
case 'u8':
|
|
283
|
+
result[fieldName] = rdata[pos++]
|
|
284
|
+
break
|
|
285
|
+
case 'u16':
|
|
286
|
+
result[fieldName] = dv.getUint16(pos)
|
|
287
|
+
pos += 2
|
|
288
|
+
break
|
|
289
|
+
case 'certtype': {
|
|
290
|
+
const certTypeNum = dv.getUint16(pos)
|
|
291
|
+
const reverse = rrInstance.constructor.CERT_TYPES_REVERSE
|
|
292
|
+
result[fieldName] = reverse?.[certTypeNum] ?? certTypeNum
|
|
293
|
+
pos += 2
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
case 'u32':
|
|
297
|
+
result[fieldName] = dv.getUint32(pos)
|
|
298
|
+
pos += 4
|
|
299
|
+
break
|
|
300
|
+
case 'fqdn': {
|
|
301
|
+
const { fqdn, end } = wireUnpackDomain(rdata, pos)
|
|
302
|
+
result[fieldName] = fqdn
|
|
303
|
+
pos = end
|
|
304
|
+
break
|
|
305
|
+
}
|
|
306
|
+
case 'hex':
|
|
307
|
+
result[fieldName] = bytesToHex(rdata.subarray(pos)).toUpperCase()
|
|
308
|
+
pos = rdata.length
|
|
309
|
+
break
|
|
310
|
+
case 'base64':
|
|
311
|
+
result[fieldName] = bytesToBase64(rdata.subarray(pos))
|
|
312
|
+
pos = rdata.length
|
|
313
|
+
break
|
|
314
|
+
case 'str':
|
|
315
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos))
|
|
316
|
+
pos = rdata.length
|
|
317
|
+
break
|
|
318
|
+
case 'qstr':
|
|
319
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos))
|
|
320
|
+
pos = rdata.length
|
|
321
|
+
break
|
|
322
|
+
case 'charstr': {
|
|
323
|
+
const strLen = rdata[pos++]
|
|
324
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos, pos + strLen))
|
|
325
|
+
pos += strLen
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
case 'qcharstr': {
|
|
329
|
+
const strLen = rdata[pos++]
|
|
330
|
+
result[fieldName] = new TextDecoder().decode(rdata.subarray(pos, pos + strLen))
|
|
331
|
+
pos += strLen
|
|
332
|
+
break
|
|
333
|
+
}
|
|
334
|
+
case 'charstrs': {
|
|
335
|
+
const parts = []
|
|
336
|
+
while (pos < rdata.length) {
|
|
337
|
+
const strLen = rdata[pos++]
|
|
338
|
+
parts.push(new TextDecoder().decode(rdata.subarray(pos, pos + strLen)))
|
|
339
|
+
pos += strLen
|
|
340
|
+
}
|
|
341
|
+
result[fieldName] = parts.join('')
|
|
342
|
+
break
|
|
343
|
+
}
|
|
344
|
+
case 'svcparams':
|
|
345
|
+
result[fieldName] = svcParamsFromWire(rdata.subarray(pos))
|
|
346
|
+
pos = rdata.length
|
|
347
|
+
break
|
|
348
|
+
case 'ipv4':
|
|
349
|
+
result[fieldName] = [...rdata.subarray(pos, pos + 4)].join('.')
|
|
350
|
+
pos += 4
|
|
351
|
+
break
|
|
352
|
+
case 'ipv6': {
|
|
353
|
+
const parts = []
|
|
354
|
+
for (let j = 0; j < 16; j += 2) {
|
|
355
|
+
parts.push(
|
|
356
|
+
dv
|
|
357
|
+
.getUint16(pos + j)
|
|
358
|
+
.toString(16)
|
|
359
|
+
.padStart(4, '0'),
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
result[fieldName] = parts.join(':')
|
|
363
|
+
pos += 16
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
default:
|
|
367
|
+
result[fieldName] = rdata[pos++]
|
|
368
|
+
break
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return new rrInstance.constructor(result)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── SVCB/HTTPS parameter encoding (RFC 9460) ─────────────────────────────────
|
|
376
|
+
|
|
377
|
+
const SVCPARAM_KEYS = {
|
|
378
|
+
mandatory: 0,
|
|
379
|
+
alpn: 1,
|
|
380
|
+
'no-default-alpn': 2,
|
|
381
|
+
port: 3,
|
|
382
|
+
ipv4hint: 4,
|
|
383
|
+
ech: 5,
|
|
384
|
+
ipv6hint: 6,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Encode SVCB/HTTPS params string (e.g. 'alpn="h2,h3" port="8443"') to wire bytes.
|
|
389
|
+
*/
|
|
390
|
+
export function svcParamsToWire(paramsStr) {
|
|
391
|
+
if (!paramsStr || !paramsStr.trim()) return new Uint8Array(0)
|
|
392
|
+
|
|
393
|
+
const parts = []
|
|
394
|
+
// Match: key, key=unquoted, or key="quoted" tokens
|
|
395
|
+
const re = /([^\s=]+)(?:=(?:"([^"]*)"|(\S*)))?(?=\s|$)/g
|
|
396
|
+
let m
|
|
397
|
+
while ((m = re.exec(paramsStr.trim())) !== null) {
|
|
398
|
+
const key = m[1].toLowerCase()
|
|
399
|
+
const val = m[2] ?? m[3] ?? ''
|
|
400
|
+
const keyId = SVCPARAM_KEYS[key]
|
|
401
|
+
if (keyId === undefined) continue
|
|
402
|
+
|
|
403
|
+
let valBytes
|
|
404
|
+
if (keyId === 1) {
|
|
405
|
+
// alpn: comma-separated list, each entry length-prefixed
|
|
406
|
+
const enc = new TextEncoder()
|
|
407
|
+
const entries = val.split(',').map((e) => enc.encode(e.trim()))
|
|
408
|
+
const total = entries.reduce((s, e) => s + 1 + e.length, 0)
|
|
409
|
+
valBytes = new Uint8Array(total)
|
|
410
|
+
let p = 0
|
|
411
|
+
for (const e of entries) {
|
|
412
|
+
valBytes[p++] = e.length
|
|
413
|
+
valBytes.set(e, p)
|
|
414
|
+
p += e.length
|
|
415
|
+
}
|
|
416
|
+
} else if (keyId === 2) {
|
|
417
|
+
// no-default-alpn: no value
|
|
418
|
+
valBytes = new Uint8Array(0)
|
|
419
|
+
} else if (keyId === 3) {
|
|
420
|
+
// port: uint16
|
|
421
|
+
valBytes = new Uint8Array(2)
|
|
422
|
+
new DataView(valBytes.buffer).setUint16(0, parseInt(val, 10))
|
|
423
|
+
} else if (keyId === 4) {
|
|
424
|
+
// ipv4hint: one or more IPv4 addresses
|
|
425
|
+
const addrs = val.split(',').map((a) => a.trim().split('.').map(Number))
|
|
426
|
+
valBytes = new Uint8Array(addrs.length * 4)
|
|
427
|
+
addrs.forEach((addr, i) => addr.forEach((b, j) => (valBytes[i * 4 + j] = b)))
|
|
428
|
+
} else if (keyId === 5) {
|
|
429
|
+
// ech: base64-encoded ECH config
|
|
430
|
+
valBytes = base64ToBytes(val)
|
|
431
|
+
} else if (keyId === 6) {
|
|
432
|
+
// ipv6hint: one or more IPv6 addresses (expand compressed form before encoding)
|
|
433
|
+
const addrs = val.split(',').map((a) => hexToBytes(expandIPv6(a.trim(), '')))
|
|
434
|
+
valBytes = new Uint8Array(addrs.length * 16)
|
|
435
|
+
addrs.forEach((addr, i) => valBytes.set(addr, i * 16))
|
|
436
|
+
} else {
|
|
437
|
+
valBytes = new TextEncoder().encode(val)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const param = new Uint8Array(4 + valBytes.length)
|
|
441
|
+
new DataView(param.buffer).setUint16(0, keyId)
|
|
442
|
+
new DataView(param.buffer).setUint16(2, valBytes.length)
|
|
443
|
+
param.set(valBytes, 4)
|
|
444
|
+
parts.push(param)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Sort by key ID (SvcParams MUST be in ascending order per RFC 9460)
|
|
448
|
+
parts.sort((a, b) => new DataView(a.buffer).getUint16(0) - new DataView(b.buffer).getUint16(0))
|
|
449
|
+
|
|
450
|
+
const total = parts.reduce((s, p) => s + p.length, 0)
|
|
451
|
+
const result = new Uint8Array(total)
|
|
452
|
+
let pos = 0
|
|
453
|
+
for (const p of parts) {
|
|
454
|
+
result.set(p, pos)
|
|
455
|
+
pos += p.length
|
|
456
|
+
}
|
|
457
|
+
return result
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const SVCPARAM_KEY_NAMES = {
|
|
461
|
+
0: 'mandatory',
|
|
462
|
+
1: 'alpn',
|
|
463
|
+
2: 'no-default-alpn',
|
|
464
|
+
3: 'port',
|
|
465
|
+
4: 'ipv4hint',
|
|
466
|
+
5: 'ech',
|
|
467
|
+
6: 'ipv6hint',
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Decode SVCB/HTTPS wire params bytes back to a human-readable params string.
|
|
472
|
+
*/
|
|
473
|
+
export function svcParamsFromWire(bytes) {
|
|
474
|
+
if (!bytes || bytes.length === 0) return ''
|
|
475
|
+
const parts = []
|
|
476
|
+
let pos = 0
|
|
477
|
+
while (pos + 4 <= bytes.length) {
|
|
478
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset + pos)
|
|
479
|
+
const keyId = dv.getUint16(0)
|
|
480
|
+
const valLen = dv.getUint16(2)
|
|
481
|
+
const val = bytes.subarray(pos + 4, pos + 4 + valLen)
|
|
482
|
+
pos += 4 + valLen
|
|
483
|
+
|
|
484
|
+
const keyName = SVCPARAM_KEY_NAMES[keyId] ?? `key${keyId}`
|
|
485
|
+
if (keyId === 1) {
|
|
486
|
+
// alpn: comma-separated length-prefixed entries
|
|
487
|
+
const entries = []
|
|
488
|
+
let p = 0
|
|
489
|
+
while (p < val.length) {
|
|
490
|
+
const len = val[p++]
|
|
491
|
+
entries.push(new TextDecoder().decode(val.subarray(p, p + len)))
|
|
492
|
+
p += len
|
|
493
|
+
}
|
|
494
|
+
parts.push(`alpn="${entries.join(',')}"`)
|
|
495
|
+
} else if (keyId === 2) {
|
|
496
|
+
parts.push('no-default-alpn')
|
|
497
|
+
} else if (keyId === 3) {
|
|
498
|
+
parts.push(`port=${new DataView(val.buffer, val.byteOffset).getUint16(0)}`)
|
|
499
|
+
} else if (keyId === 4) {
|
|
500
|
+
const addrs = []
|
|
501
|
+
for (let i = 0; i < val.length; i += 4) addrs.push([...val.subarray(i, i + 4)].join('.'))
|
|
502
|
+
parts.push(`ipv4hint=${addrs.join(',')}`)
|
|
503
|
+
} else if (keyId === 5) {
|
|
504
|
+
parts.push(`ech=${btoa([...val].map((b) => String.fromCharCode(b)).join(''))}`)
|
|
505
|
+
} else if (keyId === 6) {
|
|
506
|
+
const addrs = []
|
|
507
|
+
for (let i = 0; i < val.length; i += 16) {
|
|
508
|
+
const dv16 = new DataView(val.buffer, val.byteOffset + i)
|
|
509
|
+
const groups = []
|
|
510
|
+
for (let j = 0; j < 16; j += 2) groups.push(dv16.getUint16(j).toString(16).padStart(4, '0'))
|
|
511
|
+
addrs.push(groups.join(':'))
|
|
512
|
+
}
|
|
513
|
+
parts.push(`ipv6hint=${addrs.join(',')}`)
|
|
514
|
+
} else {
|
|
515
|
+
parts.push(`${keyName}=${[...val].map((b) => b.toString(16).padStart(2, '0')).join('')}`)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return parts.join(' ')
|
|
519
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nictool/dns-resource-record",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "DNS Resource Records",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"browser": "./dist/dns-rr.min.js",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
6
12
|
"files": [
|
|
13
|
+
"dist",
|
|
7
14
|
"lib",
|
|
8
15
|
"rr",
|
|
9
16
|
"CHANGELOG.md",
|
|
@@ -11,14 +18,15 @@
|
|
|
11
18
|
],
|
|
12
19
|
"type": "module",
|
|
13
20
|
"scripts": {
|
|
14
|
-
"build": "
|
|
15
|
-
"
|
|
21
|
+
"build": "npm run build:browser",
|
|
22
|
+
"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",
|
|
23
|
+
"clean": "rm -rf coverage dist node_modules package-lock.json",
|
|
16
24
|
"format:check": "npm run prettier; npm run lint",
|
|
17
25
|
"format": "npm run prettier -- --write && npm run lint:fix",
|
|
18
|
-
"lint": "npx eslint **/*.js",
|
|
26
|
+
"lint": "npx eslint **/*.js --no-warn-ignored",
|
|
19
27
|
"lint:fix": "npm run lint -- --fix",
|
|
20
|
-
"prettier": "npx prettier --
|
|
21
|
-
"prettier:fix": "npx prettier --
|
|
28
|
+
"prettier": "npx prettier --check .",
|
|
29
|
+
"prettier:fix": "npx prettier --write .",
|
|
22
30
|
"test": "node --test",
|
|
23
31
|
"test:coverage": "node --test --experimental-test-coverage",
|
|
24
32
|
"test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info",
|
|
@@ -49,6 +57,15 @@
|
|
|
49
57
|
"url": "https://github.com/NicTool/dns-resource-record/issues"
|
|
50
58
|
},
|
|
51
59
|
"homepage": "https://github.com/NicTool/dns-resource-record#readme",
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=22"
|
|
62
|
+
},
|
|
63
|
+
"browserslist": [
|
|
64
|
+
"chrome >= 74",
|
|
65
|
+
"firefox >= 90",
|
|
66
|
+
"safari >= 14.1",
|
|
67
|
+
"edge >= 79"
|
|
68
|
+
],
|
|
52
69
|
"devDependencies": {
|
|
53
70
|
"@eslint/js": "^10.0.1",
|
|
54
71
|
"eslint": "^10.2.1",
|
package/rr/TEMPLATE.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import RR from '../rr.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DNS Resource Record Template
|
|
5
|
+
*
|
|
6
|
+
* Use this as a starting point for creating a new Resource Record (RR) class.
|
|
7
|
+
* Replace 'TEMPLATE' with the actual record type (e.g., 'MX', 'SRV').
|
|
8
|
+
*
|
|
9
|
+
* ## Mandates
|
|
10
|
+
* - Every new RR MUST extend the `RR` class.
|
|
11
|
+
* - 100% test coverage is required for all new PRs.
|
|
12
|
+
* - Follow established patterns for naming and implementation.
|
|
13
|
+
* - **Registration**: You MUST update `index.js` to import and export the new class and add it to the `classes` array.
|
|
14
|
+
* - **Tests**: You MUST create a corresponding test file in `test/rr/<type>.js` (mirroring an existing test file like `test/rr/a.js`).
|
|
15
|
+
*
|
|
16
|
+
* ## `rdataFields` and Avoiding Boilerplate
|
|
17
|
+
* The `rdataFields` static property defines the fields in the RDATA section.
|
|
18
|
+
* It is an array where each element can be:
|
|
19
|
+
* - A string: The field name.
|
|
20
|
+
* - An array: `[fieldName, type]`
|
|
21
|
+
*
|
|
22
|
+
* Supported types for `setTypedValue`:
|
|
23
|
+
* - `u8`, `u16`, `u32`: Unsigned integers of 8, 16, or 32 bits.
|
|
24
|
+
* - `fqdn`: A fully qualified domain name.
|
|
25
|
+
* - `qstr`: Quoted string field (BIND quoting behavior).
|
|
26
|
+
* - `charstr`: DNS length-prefixed character-string (max 255 bytes).
|
|
27
|
+
* - `qcharstr`: Quoted DNS length-prefixed character-string.
|
|
28
|
+
* - `charstrs`: Concatenated DNS character-strings.
|
|
29
|
+
* - `svcparams`: SVCB/HTTPS params string.
|
|
30
|
+
* - `base64`: Base64 encoded data.
|
|
31
|
+
* - `hex`: Hexadecimal encoded data.
|
|
32
|
+
* - `str`: A simple string.
|
|
33
|
+
* - `ipv4`: An IPv4 address.
|
|
34
|
+
* - `ipv6`: An IPv6 address.
|
|
35
|
+
*
|
|
36
|
+
* If you provide a type in `rdataFields`, the base `RR` class automatically
|
|
37
|
+
* handles validation and setting of that field. You only need to implement
|
|
38
|
+
* a custom `set<FieldName>` method if you need more complex validation
|
|
39
|
+
* logic than what the default types provide.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export default class TEMPLATE extends RR {
|
|
43
|
+
// The DNS record type name (e.g., 'A', 'MX', 'TXT')
|
|
44
|
+
static typeName = 'TEMPLATE'
|
|
45
|
+
static typeId = 0
|
|
46
|
+
static RFCs = []
|
|
47
|
+
static tags = []
|
|
48
|
+
|
|
49
|
+
// The character used in tinydns format (optional)
|
|
50
|
+
static tinydnsType = 'T'
|
|
51
|
+
|
|
52
|
+
// Define the RDATA fields.
|
|
53
|
+
// Using types here avoids the need for manual setters in many cases.
|
|
54
|
+
static rdataFields = [
|
|
55
|
+
['field1', 'u16'],
|
|
56
|
+
['field2', 'fqdn'],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
constructor(opts) {
|
|
60
|
+
super(opts)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/****** Resource record specific setters *******/
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
// Example of a custom setter. Only implement if default validation
|
|
67
|
+
// from `rdataFields` is insufficient.
|
|
68
|
+
setField1(val) {
|
|
69
|
+
if (val === undefined) val = this?.default?.field1
|
|
70
|
+
if (val === undefined) this.throwHelp('TEMPLATE: field1 is required')
|
|
71
|
+
this.is16bitInt('TEMPLATE', 'field1', val)
|
|
72
|
+
this.set('field1', parseInt(val, 10))
|
|
73
|
+
}
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/****** Metadata (Required) *******/
|
|
77
|
+
|
|
78
|
+
getDescription() {
|
|
79
|
+
return 'Short description of the TEMPLATE record'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getCanonical() {
|
|
83
|
+
// Returns a sample object representing a valid record.
|
|
84
|
+
// This is used for documentation and testing.
|
|
85
|
+
return {
|
|
86
|
+
owner: 'example.com.',
|
|
87
|
+
ttl: 3600,
|
|
88
|
+
class: 'IN',
|
|
89
|
+
type: 'TEMPLATE',
|
|
90
|
+
field1: 10,
|
|
91
|
+
field2: 'target.example.com.',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/****** IMPORTERS / EXPORTERS (Recommended) *******/
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
// Optional: Override if generic implementation in RR.js is insufficient
|
|
99
|
+
fromTinydns({ tinyline }) {
|
|
100
|
+
// Implementation...
|
|
101
|
+
}
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
// Optional: Override if generic implementation in RR.js is insufficient
|
|
106
|
+
toTinydns() {
|
|
107
|
+
// Implementation...
|
|
108
|
+
}
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
getWireRdata() {
|
|
112
|
+
// MUST be implemented for binary/wire format support.
|
|
113
|
+
// Returns a Uint8Array.
|
|
114
|
+
// Example:
|
|
115
|
+
/*
|
|
116
|
+
const domain = this.wirePackDomain(this.get('field2'))
|
|
117
|
+
const result = new Uint8Array(2 + domain.length)
|
|
118
|
+
new DataView(result.buffer).setUint16(0, this.get('field1'))
|
|
119
|
+
result.set(domain, 2)
|
|
120
|
+
return result
|
|
121
|
+
*/
|
|
122
|
+
throw new Error('getWireRdata not implemented')
|
|
123
|
+
}
|
|
124
|
+
}
|