dns-sd-browser 1.0.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/LICENSE +21 -0
- package/README.md +670 -0
- package/dist/browser.d.ts +145 -0
- package/dist/constants.d.ts +12 -0
- package/dist/dns.d.ts +155 -0
- package/dist/index.d.ts +113 -0
- package/dist/service.d.ts +115 -0
- package/dist/transport.d.ts +67 -0
- package/lib/browser.js +1661 -0
- package/lib/constants.js +17 -0
- package/lib/dns.js +685 -0
- package/lib/index.js +252 -0
- package/lib/service.js +152 -0
- package/lib/transport.js +345 -0
- package/package.json +44 -0
package/lib/constants.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** mDNS multicast IPv4 address (RFC 6762 §3) */
|
|
2
|
+
export const MDNS_ADDRESS = '224.0.0.251'
|
|
3
|
+
|
|
4
|
+
/** mDNS multicast IPv6 address (RFC 6762 §3) */
|
|
5
|
+
export const MDNS_ADDRESS_V6 = 'FF02::FB'
|
|
6
|
+
|
|
7
|
+
/** mDNS default port (RFC 6762 §3) */
|
|
8
|
+
export const MDNS_PORT = 5353
|
|
9
|
+
|
|
10
|
+
/** mDNS multicast TTL — must be 255 per RFC 6762 §11 */
|
|
11
|
+
export const MDNS_TTL = 255
|
|
12
|
+
|
|
13
|
+
/** Meta-query name for service type enumeration (RFC 6763 §9) */
|
|
14
|
+
export const SERVICE_TYPE_ENUMERATION = '_services._dns-sd._udp.local'
|
|
15
|
+
|
|
16
|
+
/** Default domain for mDNS */
|
|
17
|
+
export const DEFAULT_DOMAIN = 'local'
|
package/lib/dns.js
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS packet encoder/decoder for mDNS.
|
|
3
|
+
*
|
|
4
|
+
* Implements the subset of DNS wire format (RFC 1035) needed for DNS-SD
|
|
5
|
+
* browsing over mDNS (RFC 6762, RFC 6763). Handles DNS name compression,
|
|
6
|
+
* the mDNS cache-flush bit, and all record types used by DNS-SD:
|
|
7
|
+
* PTR, SRV, TXT, A, AAAA.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @enum {number} */
|
|
13
|
+
export const RecordType = /** @type {const} */ ({
|
|
14
|
+
A: 1,
|
|
15
|
+
PTR: 12,
|
|
16
|
+
TXT: 16,
|
|
17
|
+
AAAA: 28,
|
|
18
|
+
SRV: 33,
|
|
19
|
+
ANY: 255,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** DNS class IN */
|
|
23
|
+
const CLASS_IN = 1;
|
|
24
|
+
|
|
25
|
+
/** mDNS cache-flush bit — high bit of the class field (RFC 6762 §10.2) */
|
|
26
|
+
const CACHE_FLUSH_BIT = 0x8000;
|
|
27
|
+
|
|
28
|
+
/** DNS name pointer mask — two highest bits set (RFC 1035 §4.1.4) */
|
|
29
|
+
const POINTER_MASK = 0xc0;
|
|
30
|
+
|
|
31
|
+
/** Maximum DNS name label length */
|
|
32
|
+
const MAX_LABEL_LENGTH = 63;
|
|
33
|
+
|
|
34
|
+
/** Maximum total DNS name length (RFC 1035 §2.3.4) */
|
|
35
|
+
const MAX_NAME_LENGTH = 253;
|
|
36
|
+
|
|
37
|
+
/** Minimum DNS packet size: 12-byte header */
|
|
38
|
+
const MIN_PACKET_SIZE = 12;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maximum number of resource records we'll parse from a single packet.
|
|
42
|
+
* Prevents CPU exhaustion from crafted packets with huge counts in the header.
|
|
43
|
+
* Normal mDNS responses rarely exceed ~30 records.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_RECORDS_PER_PACKET = 256;
|
|
46
|
+
|
|
47
|
+
/** Shared TextDecoder instance for DNS label decoding */
|
|
48
|
+
const textDecoder = new TextDecoder();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {object} DnsFlags
|
|
52
|
+
* @property {boolean} qr - true for response, false for query
|
|
53
|
+
* @property {number} opcode
|
|
54
|
+
* @property {boolean} aa - Authoritative Answer
|
|
55
|
+
* @property {boolean} tc - Truncated
|
|
56
|
+
* @property {boolean} rd - Recursion Desired
|
|
57
|
+
* @property {boolean} ra - Recursion Available
|
|
58
|
+
* @property {number} rcode - Response code
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} DnsQuestion
|
|
63
|
+
* @property {string} name
|
|
64
|
+
* @property {number} type - RecordType value
|
|
65
|
+
* @property {boolean} [qu] - QU (unicast-response) bit (RFC 6762 §5.4)
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {object} SrvData
|
|
70
|
+
* @property {number} priority
|
|
71
|
+
* @property {number} weight
|
|
72
|
+
* @property {number} port
|
|
73
|
+
* @property {string} target
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {object} DnsRecord
|
|
78
|
+
* @property {string} name
|
|
79
|
+
* @property {number} type - RecordType value
|
|
80
|
+
* @property {number} class - Usually 1 (IN)
|
|
81
|
+
* @property {boolean} cacheFlush - mDNS cache-flush bit
|
|
82
|
+
* @property {number} ttl
|
|
83
|
+
* @property {string | SrvData | Uint8Array[]} data - Type-specific data
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {object} DnsPacket
|
|
88
|
+
* @property {number} id
|
|
89
|
+
* @property {DnsFlags} flags
|
|
90
|
+
* @property {DnsQuestion[]} questions
|
|
91
|
+
* @property {DnsRecord[]} answers
|
|
92
|
+
* @property {DnsRecord[]} authorities
|
|
93
|
+
* @property {DnsRecord[]} additionals
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decode a DNS packet from a buffer.
|
|
98
|
+
* @param {Uint8Array} buf
|
|
99
|
+
* @returns {DnsPacket}
|
|
100
|
+
*/
|
|
101
|
+
export function decode(buf) {
|
|
102
|
+
if (buf.byteLength < MIN_PACKET_SIZE) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`DNS packet too short: ${buf.byteLength} bytes (minimum ${MIN_PACKET_SIZE})`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
109
|
+
let offset = 0;
|
|
110
|
+
|
|
111
|
+
// Header (RFC 1035 §4.1.1) — 12 bytes
|
|
112
|
+
const id = view.getUint16(offset);
|
|
113
|
+
offset += 2;
|
|
114
|
+
|
|
115
|
+
const flagBits = view.getUint16(offset);
|
|
116
|
+
offset += 2;
|
|
117
|
+
|
|
118
|
+
const flags = {
|
|
119
|
+
qr: (flagBits & 0x8000) !== 0,
|
|
120
|
+
opcode: (flagBits >> 11) & 0xf,
|
|
121
|
+
aa: (flagBits & 0x0400) !== 0,
|
|
122
|
+
tc: (flagBits & 0x0200) !== 0,
|
|
123
|
+
rd: (flagBits & 0x0100) !== 0,
|
|
124
|
+
ra: (flagBits & 0x0080) !== 0,
|
|
125
|
+
rcode: flagBits & 0x000f,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const qdcount = view.getUint16(offset);
|
|
129
|
+
offset += 2;
|
|
130
|
+
const ancount = view.getUint16(offset);
|
|
131
|
+
offset += 2;
|
|
132
|
+
const nscount = view.getUint16(offset);
|
|
133
|
+
offset += 2;
|
|
134
|
+
const arcount = view.getUint16(offset);
|
|
135
|
+
offset += 2;
|
|
136
|
+
|
|
137
|
+
// Cap record counts to prevent CPU exhaustion from crafted headers
|
|
138
|
+
const totalRecords = qdcount + ancount + nscount + arcount;
|
|
139
|
+
if (totalRecords > MAX_RECORDS_PER_PACKET) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`DNS packet claims ${totalRecords} records (maximum ${MAX_RECORDS_PER_PACKET})`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const questions = [];
|
|
146
|
+
for (let i = 0; i < qdcount; i++) {
|
|
147
|
+
const { name, offset: newOffset } = decodeName(buf, offset);
|
|
148
|
+
offset = newOffset;
|
|
149
|
+
const qtype = view.getUint16(offset);
|
|
150
|
+
offset += 2;
|
|
151
|
+
const qclassBits = view.getUint16(offset);
|
|
152
|
+
offset += 2;
|
|
153
|
+
const qu = (qclassBits & CACHE_FLUSH_BIT) !== 0;
|
|
154
|
+
questions.push({ name, type: qtype, qu });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const answers = decodeRecords(buf, view, offset, ancount);
|
|
158
|
+
offset = answers.newOffset;
|
|
159
|
+
const authorities = decodeRecords(buf, view, offset, nscount);
|
|
160
|
+
offset = authorities.newOffset;
|
|
161
|
+
const additionals = decodeRecords(buf, view, offset, arcount);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
flags,
|
|
166
|
+
questions,
|
|
167
|
+
answers: answers.records,
|
|
168
|
+
authorities: authorities.records,
|
|
169
|
+
additionals: additionals.records,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decode N resource records starting at the given offset.
|
|
175
|
+
* @param {Uint8Array} buf
|
|
176
|
+
* @param {DataView} view
|
|
177
|
+
* @param {number} offset
|
|
178
|
+
* @param {number} count
|
|
179
|
+
* @returns {{ records: DnsRecord[], newOffset: number }}
|
|
180
|
+
*/
|
|
181
|
+
function decodeRecords(buf, view, offset, count) {
|
|
182
|
+
const records = [];
|
|
183
|
+
for (let i = 0; i < count; i++) {
|
|
184
|
+
const { name, offset: nameEnd } = decodeName(buf, offset);
|
|
185
|
+
offset = nameEnd;
|
|
186
|
+
|
|
187
|
+
// Need at least 10 bytes for type(2) + class(2) + ttl(4) + rdlength(2)
|
|
188
|
+
if (offset + 10 > buf.byteLength) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
"DNS record truncated: not enough bytes for record metadata",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const type = view.getUint16(offset);
|
|
195
|
+
offset += 2;
|
|
196
|
+
const classBits = view.getUint16(offset);
|
|
197
|
+
offset += 2;
|
|
198
|
+
const cacheFlush = (classBits & CACHE_FLUSH_BIT) !== 0;
|
|
199
|
+
const rrClass = classBits & ~CACHE_FLUSH_BIT;
|
|
200
|
+
|
|
201
|
+
const ttl = view.getUint32(offset);
|
|
202
|
+
offset += 4;
|
|
203
|
+
const rdlength = view.getUint16(offset);
|
|
204
|
+
offset += 2;
|
|
205
|
+
|
|
206
|
+
// Validate RDATA fits within the packet
|
|
207
|
+
if (offset + rdlength > buf.byteLength) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`DNS record RDATA overflows packet: offset=${offset} rdlength=${rdlength} bufLen=${buf.byteLength}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const data = decodeRecordData(buf, view, offset, type, rdlength);
|
|
214
|
+
offset += rdlength;
|
|
215
|
+
|
|
216
|
+
records.push({ name, type, class: rrClass, cacheFlush, ttl, data });
|
|
217
|
+
}
|
|
218
|
+
return { records, newOffset: offset };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Decode record-type-specific data (RDATA).
|
|
223
|
+
* @param {Uint8Array} buf
|
|
224
|
+
* @param {DataView} view
|
|
225
|
+
* @param {number} offset - Start of RDATA
|
|
226
|
+
* @param {number} type - Record type
|
|
227
|
+
* @param {number} rdlength - Length of RDATA
|
|
228
|
+
* @returns {string | SrvData | Uint8Array[]}
|
|
229
|
+
*/
|
|
230
|
+
function decodeRecordData(buf, view, offset, type, rdlength) {
|
|
231
|
+
switch (type) {
|
|
232
|
+
case RecordType.A: {
|
|
233
|
+
if (rdlength !== 4) return "";
|
|
234
|
+
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case RecordType.AAAA: {
|
|
238
|
+
if (rdlength !== 16) return "";
|
|
239
|
+
const parts = [];
|
|
240
|
+
for (let i = 0; i < 16; i += 2) {
|
|
241
|
+
parts.push(view.getUint16(offset + i).toString(16));
|
|
242
|
+
}
|
|
243
|
+
return compressIPv6(parts.join(":"));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case RecordType.PTR: {
|
|
247
|
+
const { name } = decodeName(buf, offset);
|
|
248
|
+
return name;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case RecordType.SRV: {
|
|
252
|
+
// SRV RDATA: priority(2) + weight(2) + port(2) + target (min 1 byte for root)
|
|
253
|
+
if (rdlength < 7) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`SRV record RDATA too short: ${rdlength} bytes (minimum 7)`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const priority = view.getUint16(offset);
|
|
259
|
+
const weight = view.getUint16(offset + 2);
|
|
260
|
+
const port = view.getUint16(offset + 4);
|
|
261
|
+
const { name: target } = decodeName(buf, offset + 6);
|
|
262
|
+
return { priority, weight, port, target };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case RecordType.TXT: {
|
|
266
|
+
return decodeTxtData(buf, offset, rdlength);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
default: {
|
|
270
|
+
// Return raw bytes for unknown record types
|
|
271
|
+
return [buf.slice(offset, offset + rdlength)];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Decode TXT record data into an array of Uint8Array strings.
|
|
278
|
+
* Each string is length-prefixed (RFC 1035 §3.3.14).
|
|
279
|
+
* @param {Uint8Array} buf
|
|
280
|
+
* @param {number} offset
|
|
281
|
+
* @param {number} rdlength
|
|
282
|
+
* @returns {Uint8Array[]}
|
|
283
|
+
*/
|
|
284
|
+
function decodeTxtData(buf, offset, rdlength) {
|
|
285
|
+
const strings = [];
|
|
286
|
+
const end = offset + rdlength;
|
|
287
|
+
while (offset < end) {
|
|
288
|
+
const len = buf[offset];
|
|
289
|
+
offset += 1;
|
|
290
|
+
if (len === 0) {
|
|
291
|
+
// Empty string — represents an empty TXT record (RFC 6763 §6.1)
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Validate TXT string length stays within RDATA boundary
|
|
295
|
+
if (offset + len > end) {
|
|
296
|
+
throw new Error("TXT record string length exceeds RDATA boundary");
|
|
297
|
+
}
|
|
298
|
+
strings.push(buf.slice(offset, offset + len));
|
|
299
|
+
offset += len;
|
|
300
|
+
}
|
|
301
|
+
return strings;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Decode a DNS name from the buffer, handling compression pointers.
|
|
306
|
+
* @param {Uint8Array} buf
|
|
307
|
+
* @param {number} offset
|
|
308
|
+
* @returns {{ name: string, offset: number }}
|
|
309
|
+
*/
|
|
310
|
+
function decodeName(buf, offset) {
|
|
311
|
+
const labels = [];
|
|
312
|
+
let jumped = false;
|
|
313
|
+
let returnOffset = offset;
|
|
314
|
+
let totalLength = 0;
|
|
315
|
+
|
|
316
|
+
// Guard against infinite loops from malicious pointer chains
|
|
317
|
+
let maxJumps = 128;
|
|
318
|
+
while (maxJumps-- > 0) {
|
|
319
|
+
if (offset >= buf.length) {
|
|
320
|
+
throw new Error("DNS name decode: offset beyond buffer");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const len = buf[offset];
|
|
324
|
+
|
|
325
|
+
if (len === 0) {
|
|
326
|
+
// End of name
|
|
327
|
+
if (!jumped) returnOffset = offset + 1;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if ((len & POINTER_MASK) === POINTER_MASK) {
|
|
332
|
+
// Compressed name pointer (RFC 1035 §4.1.4)
|
|
333
|
+
if (offset + 1 >= buf.length) {
|
|
334
|
+
throw new Error("DNS name decode: pointer truncated");
|
|
335
|
+
}
|
|
336
|
+
if (!jumped) returnOffset = offset + 2;
|
|
337
|
+
const pointerOffset = ((len & ~POINTER_MASK) << 8) | buf[offset + 1];
|
|
338
|
+
// Pointer must target a valid position within the packet
|
|
339
|
+
if (pointerOffset >= buf.length) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`DNS name decode: pointer offset ${pointerOffset} beyond buffer length ${buf.length}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
offset = pointerOffset;
|
|
345
|
+
jumped = true;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Regular label
|
|
350
|
+
if (len > MAX_LABEL_LENGTH) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`DNS name decode: label length ${len} exceeds maximum ${MAX_LABEL_LENGTH}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
offset += 1;
|
|
357
|
+
|
|
358
|
+
// Validate label data fits in buffer
|
|
359
|
+
if (offset + len > buf.length) {
|
|
360
|
+
throw new Error("DNS name decode: label extends beyond buffer");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const label = textDecoder.decode(buf.subarray(offset, offset + len));
|
|
364
|
+
const normalizedLabel = label.normalize("NFC");
|
|
365
|
+
|
|
366
|
+
// Enforce maximum total name length (RFC 1035 §2.3.4: 253 chars)
|
|
367
|
+
totalLength += len + (labels.length > 0 ? 1 : 0); // +1 for the dot separator
|
|
368
|
+
if (totalLength > MAX_NAME_LENGTH) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`DNS name exceeds maximum length of ${MAX_NAME_LENGTH} characters`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
labels.push(normalizedLabel);
|
|
375
|
+
offset += len;
|
|
376
|
+
|
|
377
|
+
// Track return position through each label when not following pointers
|
|
378
|
+
if (!jumped) returnOffset = offset;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (maxJumps < 0) {
|
|
382
|
+
throw new Error("DNS name decode: too many compression pointers");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { name: labels.join("."), offset: returnOffset };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Compress an IPv6 address string (collapse longest run of :0: groups).
|
|
390
|
+
* @param {string} addr - Fully expanded IPv6 (8 groups of hex)
|
|
391
|
+
* @returns {string}
|
|
392
|
+
*/
|
|
393
|
+
function compressIPv6(addr) {
|
|
394
|
+
const parts = addr.split(":").map((p) => p.replace(/^0+/, "") || "0");
|
|
395
|
+
// Find longest run of consecutive '0' groups
|
|
396
|
+
let bestStart = -1;
|
|
397
|
+
let bestLen = 0;
|
|
398
|
+
let curStart = -1;
|
|
399
|
+
let curLen = 0;
|
|
400
|
+
for (let i = 0; i < parts.length; i++) {
|
|
401
|
+
if (parts[i] === "0") {
|
|
402
|
+
if (curStart === -1) curStart = i;
|
|
403
|
+
curLen++;
|
|
404
|
+
if (curLen > bestLen) {
|
|
405
|
+
bestStart = curStart;
|
|
406
|
+
bestLen = curLen;
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
curStart = -1;
|
|
410
|
+
curLen = 0;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (bestLen < 2) return parts.join(":");
|
|
415
|
+
|
|
416
|
+
const before = parts.slice(0, bestStart).join(":");
|
|
417
|
+
const after = parts.slice(bestStart + bestLen).join(":");
|
|
418
|
+
return `${before}::${after}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ─── Encoding ───────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
/** Maximum mDNS packet size before splitting (Ethernet MTU minus IP+UDP headers) */
|
|
424
|
+
const MAX_MDNS_PACKET_SIZE = 1472;
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* @typedef {object} QueryOptions
|
|
428
|
+
* @property {DnsQuestion[]} questions
|
|
429
|
+
* @property {DnsRecord[]} [answers] - Known answers for suppression (RFC 6762 §7.1)
|
|
430
|
+
*/
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Encode an mDNS query into one or more packets.
|
|
434
|
+
* If the known-answer list is too large for a single packet, splits across
|
|
435
|
+
* multiple packets per RFC 6762 §7.2: the first packet contains the question
|
|
436
|
+
* and sets the TC bit, continuation packets contain only answers (QDCOUNT=0).
|
|
437
|
+
* @param {QueryOptions} options
|
|
438
|
+
* @returns {Buffer[]} - Array of encoded packets (usually 1)
|
|
439
|
+
*/
|
|
440
|
+
export function encodeQueryPackets({ questions, answers = [] }) {
|
|
441
|
+
// Try encoding everything in one packet first
|
|
442
|
+
const single = encodeQuery({ questions, answers });
|
|
443
|
+
if (single.length <= MAX_MDNS_PACKET_SIZE || answers.length === 0) {
|
|
444
|
+
return [single];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Too large — split known answers across multiple packets.
|
|
448
|
+
// First packet: question + as many answers as fit, with TC bit set.
|
|
449
|
+
const packets = [];
|
|
450
|
+
let remaining = [...answers];
|
|
451
|
+
|
|
452
|
+
// Encode question-only packet to measure base size
|
|
453
|
+
const questionOnly = encodeQuery({ questions, answers: [] });
|
|
454
|
+
const baseSize = questionOnly.length;
|
|
455
|
+
|
|
456
|
+
// Greedily add answers to the first packet
|
|
457
|
+
let firstBatch = [];
|
|
458
|
+
let estimatedSize = baseSize;
|
|
459
|
+
while (remaining.length > 0) {
|
|
460
|
+
const trial = encodeQuery({ questions: [], answers: [remaining[0]] });
|
|
461
|
+
const recordSize = trial.length - 12; // subtract header
|
|
462
|
+
if (
|
|
463
|
+
estimatedSize + recordSize > MAX_MDNS_PACKET_SIZE &&
|
|
464
|
+
firstBatch.length > 0
|
|
465
|
+
)
|
|
466
|
+
break;
|
|
467
|
+
firstBatch.push(/** @type {DnsRecord} */ (remaining.shift()));
|
|
468
|
+
estimatedSize += recordSize;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Encode first packet with TC bit set
|
|
472
|
+
const firstPkt = encodeQuery({ questions, answers: firstBatch });
|
|
473
|
+
// Set TC bit: byte 2, bit 1
|
|
474
|
+
firstPkt[2] = firstPkt[2] | 0x02;
|
|
475
|
+
packets.push(firstPkt);
|
|
476
|
+
|
|
477
|
+
// Continuation packets: answers only, no question, no TC
|
|
478
|
+
while (remaining.length > 0) {
|
|
479
|
+
let batch = [];
|
|
480
|
+
let size = 12; // header only
|
|
481
|
+
while (remaining.length > 0) {
|
|
482
|
+
const trial = encodeQuery({ questions: [], answers: [remaining[0]] });
|
|
483
|
+
const recordSize = trial.length - 12;
|
|
484
|
+
if (size + recordSize > MAX_MDNS_PACKET_SIZE && batch.length > 0) break;
|
|
485
|
+
batch.push(/** @type {DnsRecord} */ (remaining.shift()));
|
|
486
|
+
size += recordSize;
|
|
487
|
+
}
|
|
488
|
+
packets.push(encodeQuery({ questions: [], answers: batch }));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return packets;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Encode a single mDNS query packet.
|
|
496
|
+
* @param {QueryOptions} options
|
|
497
|
+
* @returns {Buffer}
|
|
498
|
+
*/
|
|
499
|
+
export function encodeQuery({ questions, answers = [] }) {
|
|
500
|
+
/** @type {Map<string, number>} - Name compression table: name → offset */
|
|
501
|
+
const compressionTable = new Map();
|
|
502
|
+
|
|
503
|
+
// Header — 12 bytes
|
|
504
|
+
const header = Buffer.alloc(12);
|
|
505
|
+
// ID = 0 for mDNS queries (RFC 6762 §18.1)
|
|
506
|
+
header.writeUInt16BE(0, 0);
|
|
507
|
+
// Flags = 0 for standard query
|
|
508
|
+
header.writeUInt16BE(0, 2);
|
|
509
|
+
// QDCOUNT
|
|
510
|
+
header.writeUInt16BE(questions.length, 4);
|
|
511
|
+
// ANCOUNT (known answers for suppression)
|
|
512
|
+
header.writeUInt16BE(answers.length, 6);
|
|
513
|
+
// NSCOUNT
|
|
514
|
+
header.writeUInt16BE(0, 8);
|
|
515
|
+
// ARCOUNT
|
|
516
|
+
header.writeUInt16BE(0, 10);
|
|
517
|
+
|
|
518
|
+
/** @type {Buffer[]} */
|
|
519
|
+
const parts = [header];
|
|
520
|
+
let currentOffset = 12;
|
|
521
|
+
|
|
522
|
+
// Encode questions
|
|
523
|
+
for (const q of questions) {
|
|
524
|
+
const nameBytes = encodeName(q.name, compressionTable, currentOffset);
|
|
525
|
+
const qFooter = Buffer.alloc(4);
|
|
526
|
+
qFooter.writeUInt16BE(q.type, 0);
|
|
527
|
+
// QU bit in the class field for mDNS unicast-response
|
|
528
|
+
qFooter.writeUInt16BE(q.qu ? CLASS_IN | CACHE_FLUSH_BIT : CLASS_IN, 2);
|
|
529
|
+
|
|
530
|
+
parts.push(nameBytes, qFooter);
|
|
531
|
+
currentOffset += nameBytes.length + 4;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Encode known answers
|
|
535
|
+
for (const record of answers) {
|
|
536
|
+
const recordBytes = encodeRecord(record, compressionTable, currentOffset);
|
|
537
|
+
parts.push(recordBytes);
|
|
538
|
+
currentOffset += recordBytes.length;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return Buffer.concat(parts);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Encode a single resource record.
|
|
546
|
+
* @param {DnsRecord} record
|
|
547
|
+
* @param {Map<string, number>} compressionTable
|
|
548
|
+
* @param {number} currentOffset
|
|
549
|
+
* @returns {Buffer}
|
|
550
|
+
*/
|
|
551
|
+
function encodeRecord(record, compressionTable, currentOffset) {
|
|
552
|
+
const nameBytes = encodeName(record.name, compressionTable, currentOffset);
|
|
553
|
+
currentOffset += nameBytes.length;
|
|
554
|
+
|
|
555
|
+
const rdata = encodeRecordData(record, compressionTable, currentOffset + 10);
|
|
556
|
+
|
|
557
|
+
const meta = Buffer.alloc(10);
|
|
558
|
+
meta.writeUInt16BE(record.type, 0);
|
|
559
|
+
const classBits =
|
|
560
|
+
(record.class || CLASS_IN) | (record.cacheFlush ? CACHE_FLUSH_BIT : 0);
|
|
561
|
+
meta.writeUInt16BE(classBits, 2);
|
|
562
|
+
meta.writeUInt32BE(record.ttl, 4);
|
|
563
|
+
meta.writeUInt16BE(rdata.length, 8);
|
|
564
|
+
|
|
565
|
+
return Buffer.concat([nameBytes, meta, rdata]);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Encode record-type-specific data.
|
|
570
|
+
* @param {DnsRecord} record
|
|
571
|
+
* @param {Map<string, number>} compressionTable
|
|
572
|
+
* @param {number} rdataOffset
|
|
573
|
+
* @returns {Buffer}
|
|
574
|
+
*/
|
|
575
|
+
function encodeRecordData(record, compressionTable, rdataOffset) {
|
|
576
|
+
switch (record.type) {
|
|
577
|
+
case RecordType.A: {
|
|
578
|
+
const data = /** @type {string} */ (record.data);
|
|
579
|
+
const parts = data.split(".").map(Number);
|
|
580
|
+
return Buffer.from(parts);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
case RecordType.AAAA: {
|
|
584
|
+
const data = /** @type {string} */ (record.data);
|
|
585
|
+
return encodeIPv6(data);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
case RecordType.PTR: {
|
|
589
|
+
const data = /** @type {string} */ (record.data);
|
|
590
|
+
return encodeName(data, compressionTable, rdataOffset);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case RecordType.SRV: {
|
|
594
|
+
const data = /** @type {SrvData} */ (record.data);
|
|
595
|
+
const header = Buffer.alloc(6);
|
|
596
|
+
header.writeUInt16BE(data.priority, 0);
|
|
597
|
+
header.writeUInt16BE(data.weight, 2);
|
|
598
|
+
header.writeUInt16BE(data.port, 4);
|
|
599
|
+
// SRV target MUST NOT use name compression (RFC 2782)
|
|
600
|
+
const targetBytes = encodeName(data.target, new Map(), rdataOffset + 6);
|
|
601
|
+
return Buffer.concat([header, targetBytes]);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
case RecordType.TXT: {
|
|
605
|
+
const data = /** @type {Uint8Array[]} */ (record.data);
|
|
606
|
+
if (data.length === 0) return Buffer.from([0]);
|
|
607
|
+
const parts = data.map((s) => {
|
|
608
|
+
const len = Buffer.alloc(1);
|
|
609
|
+
len[0] = s.length;
|
|
610
|
+
return Buffer.concat([len, s]);
|
|
611
|
+
});
|
|
612
|
+
return Buffer.concat(parts);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/* c8 ignore next 2 -- defensive default for unknown record types */
|
|
616
|
+
default:
|
|
617
|
+
return Buffer.alloc(0);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Encode a DNS name with compression.
|
|
623
|
+
* @param {string} name
|
|
624
|
+
* @param {Map<string, number>} compressionTable
|
|
625
|
+
* @param {number} currentOffset
|
|
626
|
+
* @returns {Buffer}
|
|
627
|
+
*/
|
|
628
|
+
function encodeName(name, compressionTable, currentOffset) {
|
|
629
|
+
const labels = name.split(".");
|
|
630
|
+
const parts = [];
|
|
631
|
+
|
|
632
|
+
for (let i = 0; i < labels.length; i++) {
|
|
633
|
+
const suffix = labels.slice(i).join(".");
|
|
634
|
+
|
|
635
|
+
// Check if this suffix is already in the compression table
|
|
636
|
+
const pointer = compressionTable.get(suffix);
|
|
637
|
+
if (pointer !== undefined) {
|
|
638
|
+
const ptrBuf = Buffer.alloc(2);
|
|
639
|
+
ptrBuf.writeUInt16BE(0xc000 | pointer, 0);
|
|
640
|
+
parts.push(ptrBuf);
|
|
641
|
+
return Buffer.concat(parts);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Store this suffix position for future compression
|
|
645
|
+
compressionTable.set(suffix, currentOffset);
|
|
646
|
+
|
|
647
|
+
let label = labels[i];
|
|
648
|
+
label = label.normalize("NFC");
|
|
649
|
+
const encoded = Buffer.from(label, "utf-8");
|
|
650
|
+
const lenBuf = Buffer.alloc(1);
|
|
651
|
+
lenBuf[0] = encoded.length;
|
|
652
|
+
parts.push(lenBuf, encoded);
|
|
653
|
+
currentOffset += 1 + encoded.length;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Null terminator
|
|
657
|
+
parts.push(Buffer.from([0]));
|
|
658
|
+
return Buffer.concat(parts);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Encode an IPv6 address to a 16-byte buffer.
|
|
663
|
+
* @param {string} addr
|
|
664
|
+
* @returns {Buffer}
|
|
665
|
+
*/
|
|
666
|
+
function encodeIPv6(addr) {
|
|
667
|
+
const buf = Buffer.alloc(16);
|
|
668
|
+
// Expand :: shorthand
|
|
669
|
+
let fullAddr = addr;
|
|
670
|
+
if (fullAddr.includes("::")) {
|
|
671
|
+
const [left, right] = fullAddr.split("::");
|
|
672
|
+
const leftParts = left ? left.split(":") : [];
|
|
673
|
+
const rightParts = right ? right.split(":") : [];
|
|
674
|
+
const missing = 8 - leftParts.length - rightParts.length;
|
|
675
|
+
const middle = Array(missing).fill("0");
|
|
676
|
+
fullAddr = [...leftParts, ...middle, ...rightParts].join(":");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const parts = fullAddr.split(":");
|
|
680
|
+
for (let i = 0; i < 8; i++) {
|
|
681
|
+
const val = parseInt(parts[i] || "0", 16);
|
|
682
|
+
buf.writeUInt16BE(val, i * 2);
|
|
683
|
+
}
|
|
684
|
+
return buf;
|
|
685
|
+
}
|