@nictool/dns-resource-record 1.6.0 → 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 +45 -6
- package/README.md +328 -198
- 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 +29 -10
- 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,18 +18,21 @@
|
|
|
11
18
|
],
|
|
12
19
|
"type": "module",
|
|
13
20
|
"scripts": {
|
|
14
|
-
"build": "
|
|
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",
|
|
15
24
|
"format:check": "npm run prettier; npm run lint",
|
|
16
25
|
"format": "npm run prettier -- --write && npm run lint:fix",
|
|
17
|
-
"lint": "npx eslint **/*.js",
|
|
26
|
+
"lint": "npx eslint **/*.js --no-warn-ignored",
|
|
18
27
|
"lint:fix": "npm run lint -- --fix",
|
|
19
|
-
"prettier": "npx prettier --
|
|
20
|
-
"prettier:fix": "npx prettier --
|
|
21
|
-
"test": "node --test
|
|
22
|
-
"test:coverage": "
|
|
28
|
+
"prettier": "npx prettier --check .",
|
|
29
|
+
"prettier:fix": "npx prettier --write .",
|
|
30
|
+
"test": "node --test",
|
|
31
|
+
"test:coverage": "node --test --experimental-test-coverage",
|
|
32
|
+
"test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info",
|
|
23
33
|
"versions": "npx npm-dep-mgr check",
|
|
24
34
|
"versions:fix": "npx npm-dep-mgr update",
|
|
25
|
-
"watch": "node --test --watch
|
|
35
|
+
"watch": "node --test --watch"
|
|
26
36
|
},
|
|
27
37
|
"repository": {
|
|
28
38
|
"type": "git",
|
|
@@ -47,10 +57,19 @@
|
|
|
47
57
|
"url": "https://github.com/NicTool/dns-resource-record/issues"
|
|
48
58
|
},
|
|
49
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
|
+
],
|
|
50
69
|
"devDependencies": {
|
|
51
70
|
"@eslint/js": "^10.0.1",
|
|
52
|
-
"eslint": "^10.1
|
|
53
|
-
"globals": "^17.
|
|
71
|
+
"eslint": "^10.2.1",
|
|
72
|
+
"globals": "^17.5.0"
|
|
54
73
|
},
|
|
55
74
|
"prettier": {
|
|
56
75
|
"printWidth": 110,
|