@twinsearth/w2m-dsh-plugin 0.5.5 → 0.5.6
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 +71 -0
- package/README.md +3 -1
- package/lib/client.js +139 -14
- package/package.json +1 -1
- package/src/agent/mdns-wire.mjs +1062 -0
- package/src/agent/mdns.mjs +1311 -0
- package/src/plugin/mdns-discover.mjs +577 -0
- package/src/plugin/tools.mjs +37 -2
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multicast DNS wire codec 鈥?a bounded, hand-written DNS message encoder and decoder.
|
|
3
|
+
*
|
|
4
|
+
* # THE RULE
|
|
5
|
+
*
|
|
6
|
+
* **Discovery is a hint, never an authorisation.** A discovered device is a *candidate to ask*. It
|
|
7
|
+
* never skips, weakens or short-circuits any existing gate 鈥?the device token, the per-machine
|
|
8
|
+
* allow-list, the git anchors, the verdict model. Nothing in this file, in `src/agent/mdns.mjs`, or in
|
|
9
|
+
* `src/plugin/mdns-discover.mjs` may treat "we saw it announce itself" as "we may run there". If a
|
|
10
|
+
* future edit finds itself writing code where a discovered device is trusted *because it was
|
|
11
|
+
* discovered*, stop: that is a defect, not an optimisation. mDNS carries no authentication of any
|
|
12
|
+
* kind, so a record here is an unverified claim by whoever sent it, and every consumer must treat it
|
|
13
|
+
* as such.
|
|
14
|
+
*
|
|
15
|
+
* # Clean room
|
|
16
|
+
*
|
|
17
|
+
* Written from the published rules 鈥?RFC 1035 搂4.1 (message format, name compression), RFC 1035
|
|
18
|
+
* 搂2.3.4 (the 255-octet name limit), RFC 1035 搂3.1/搂3.2.2 (record and type definitions), RFC 2782
|
|
19
|
+
* (SRV), RFC 6763 (DNS-SD record layout, the TXT record's key=value convention), RFC 6762 搂18
|
|
20
|
+
* (mDNS message format) and RFC 6762 搂10 (TTL values, goodbye packets). No third-party
|
|
21
|
+
* implementation was consulted line by line and none was copied: this project is MIT and stays free of
|
|
22
|
+
* third-party notice entanglement, and deriving from the specification is also what lets us *not*
|
|
23
|
+
* reproduce the defects a studied implementation had.
|
|
24
|
+
*
|
|
25
|
+
* Two behaviours are deliberately not reproduced anywhere in this module, because they are how a
|
|
26
|
+
* discovery layer goes stale:
|
|
27
|
+
*
|
|
28
|
+
* * **first-writer-wins / keep-first-non-zero-port.** There is no such rule here. A record is
|
|
29
|
+
* identified by `(name, type, rdata)` and a later copy *replaces* an earlier one, at any TTL
|
|
30
|
+
* including zero: see `createRecordCache` in `src/agent/mdns.mjs`. A device that moves during a
|
|
31
|
+
* scan must be seen at its new address, not at the address it had when the scan began.
|
|
32
|
+
* * **a name as identity.** Nothing here decides identity at all; the codec only reports what the
|
|
33
|
+
* wire said, including the exact `name` bytes as written. Identity is decided in
|
|
34
|
+
* `src/plugin/mdns-discover.mjs`, by a `machine_id` the relay assigned 鈥?never by a matching
|
|
35
|
+
* service or host name.
|
|
36
|
+
*
|
|
37
|
+
* # What is implemented, and what is not
|
|
38
|
+
*
|
|
39
|
+
* Implemented: header, question section, and the answer/authority/additional sections for PTR, SRV,
|
|
40
|
+
* TXT, A and AAAA. Name compression on **decode** (pointers are followed, with hardening below) and on
|
|
41
|
+
* **encode** (a per-message suffix dictionary writes a pointer to an earlier occurrence).
|
|
42
|
+
*
|
|
43
|
+
* Not implemented, on purpose: NSEC/negative responses, OPT/EDNS0, DNSSEC, SVCB/HTTPS, and every
|
|
44
|
+
* record type this layer does not read. An unknown record type decodes into `{type, rdata}` and is
|
|
45
|
+
* ignored by the consumers rather than guessed at.
|
|
46
|
+
*
|
|
47
|
+
* # The hardening contract
|
|
48
|
+
*
|
|
49
|
+
* `decodeMessage` **never throws and never loops forever**, whatever bytes it is handed. Every read is
|
|
50
|
+
* bounds-checked against the buffer, and every structural limit is a refusal with a stable code:
|
|
51
|
+
*
|
|
52
|
+
* | guard | limit | refusal |
|
|
53
|
+
* | ---------------------------- | ----- | -------------------- |
|
|
54
|
+
* | label length octet | 63 | `MDNS_LABEL_TOO_LONG`|
|
|
55
|
+
* | unsupported label kind (01/10)| 鈥? | `MDNS_LABEL_TYPE` |
|
|
56
|
+
* | expanded name length | 255 | `MDNS_NAME_TOO_LONG` |
|
|
57
|
+
* | compression pointer target | must be strictly earlier in the message | `MDNS_POINTER_LOOP` |
|
|
58
|
+
* | compression pointer hops | 128 | `MDNS_POINTER_HOPS` |
|
|
59
|
+
* | records per message | 256 | `MDNS_RECORD_COUNT` |
|
|
60
|
+
* | message size | 9000 | `MDNS_MESSAGE_TOO_LARGE` |
|
|
61
|
+
* | any read past the end | 鈥? | `MDNS_TRUNCATED` |
|
|
62
|
+
*
|
|
63
|
+
* The two pointer guards are both load-bearing, and neither is decoration:
|
|
64
|
+
*
|
|
65
|
+
* * RFC 1035 搂4.1.4 says a pointer refers to "a prior occurrence" of the name, i.e. to an offset
|
|
66
|
+
* *earlier* in the message. A pointer that does not point strictly backwards is the only way to
|
|
67
|
+
* build a cycle, so it is refused as a loop. This alone guarantees termination.
|
|
68
|
+
* * The hop counter (128, the conventional guard) is the bound on *work*, not just on loops: a
|
|
69
|
+
* hostile packet can chain 200 legal, strictly-backwards pointers into one name, which terminates
|
|
70
|
+
* but costs more than any honest message needs.
|
|
71
|
+
*
|
|
72
|
+
* The last line of defence is a `try/catch` around the whole decoder: a bug in the code below becomes
|
|
73
|
+
* `MDNS_INTERNAL`, a refusal, not an exception escaping into a socket handler.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
import { Buffer } from 'node:buffer';
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------------------------
|
|
79
|
+
// Constants
|
|
80
|
+
// ---------------------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** DNS CLASS IN (RFC 1035 搂3.2.4). */
|
|
83
|
+
export const DNS_CLASS_IN = 1;
|
|
84
|
+
|
|
85
|
+
/** DNS CLASS ANY (`*`), which mDNS queries use to ask for every type of a name (RFC 6762 搂5). */
|
|
86
|
+
export const DNS_CLASS_ANY = 255;
|
|
87
|
+
|
|
88
|
+
/** Record types this layer reads. RFC 1035 搂3.2.2, RFC 1035 搂3.2.3 (AAAA is RFC 3596), RFC 2782. */
|
|
89
|
+
export const RECORD_TYPE = Object.freeze({
|
|
90
|
+
A: 1,
|
|
91
|
+
PTR: 12,
|
|
92
|
+
TXT: 16,
|
|
93
|
+
AAAA: 28,
|
|
94
|
+
SRV: 33,
|
|
95
|
+
ANY: 255,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** The same codes backwards, for diagnostics. An unknown code renders as `TYPE<n>`. */
|
|
99
|
+
const TYPE_NAME = Object.freeze({
|
|
100
|
+
1: 'A',
|
|
101
|
+
12: 'PTR',
|
|
102
|
+
16: 'TXT',
|
|
103
|
+
28: 'AAAA',
|
|
104
|
+
33: 'SRV',
|
|
105
|
+
255: 'ANY',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The most significant bit of the *rrclass* field in a response record.
|
|
110
|
+
*
|
|
111
|
+
* RFC 6762 搂10.2: "this is not a record class 0x8001" 鈥?the bit means "the rest of this RRset is
|
|
112
|
+
* replaced", and it must be masked off before the class is compared. It is the *same bit value* as
|
|
113
|
+
* {@link UNICAST_RESPONSE} in a question, but a different field with a different meaning; the two
|
|
114
|
+
* constants are separate so a future edit cannot conflate them.
|
|
115
|
+
*/
|
|
116
|
+
export const CACHE_FLUSH = 0x8000;
|
|
117
|
+
|
|
118
|
+
/** The most significant bit of the *qclass* field in a question (RFC 6762 搂5.4, "QU" questions). */
|
|
119
|
+
export const UNICAST_RESPONSE = 0x8000;
|
|
120
|
+
|
|
121
|
+
/** RFC 1035 搂2.3.4: a label is at most 63 octets, which is why the top two bits can be flags. */
|
|
122
|
+
export const MAX_LABEL_BYTES = 63;
|
|
123
|
+
|
|
124
|
+
/** RFC 1035 搂2.3.4: a name (length octets, label octets and the terminating zero) is at most 255. */
|
|
125
|
+
export const MAX_NAME_BYTES = 255;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Largest message this codec will parse or produce.
|
|
129
|
+
*
|
|
130
|
+
* RFC 6762 搂17 asks that multicast messages fit in 1500 bytes "whenever possible" and caps them at
|
|
131
|
+
* 9000 (`MAX_MESSAGE_BYTES` here). The cap is also a bound on parser work, which is the reason it is
|
|
132
|
+
* enforced on *decode* as well: a datagram larger than this is refused before a single name is read.
|
|
133
|
+
*/
|
|
134
|
+
export const MAX_MESSAGE_BYTES = 9000;
|
|
135
|
+
|
|
136
|
+
/** The conventional bound on compression-pointer dereferences for one name. */
|
|
137
|
+
export const MAX_POINTER_HOPS = 128;
|
|
138
|
+
|
|
139
|
+
/** Upper bound on records in one message. A 9000-byte message cannot honestly hold more than a few. */
|
|
140
|
+
export const MAX_RECORDS = 256;
|
|
141
|
+
|
|
142
|
+
/** Upper bound on the encoder's suffix dictionary, so a huge message cannot grow it without limit. */
|
|
143
|
+
export const MAX_COMPRESSION_ENTRIES = 512;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The two top bits of a length octet (RFC 1035 搂4.1.4): `00` is a label length, `11` is a compression
|
|
147
|
+
* pointer, `01` and `10` are reserved. The mask is one octet wide 鈥?the 16-bit form, `0xc000`, is what
|
|
148
|
+
* the *writer* ORs into a pointer, and mixing the two up makes every pointer unreadable.
|
|
149
|
+
*/
|
|
150
|
+
const LABEL_KIND_MASK = 0xc0;
|
|
151
|
+
|
|
152
|
+
/** The 16-bit form the writer emits: `11` in the top two bits of the pointer word. */
|
|
153
|
+
const POINTER_WORD_MASK = 0xc000;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* RFC 6762 搂6.7: a response to a query from a source port other than 5353 is a *legacy* unicast
|
|
157
|
+
* response, and its TTLs "SHOULD NOT be greater than ten seconds" 鈥?the resolver that asked has no
|
|
158
|
+
* cache-coherency protocol and would keep the record long after it stopped being true.
|
|
159
|
+
*/
|
|
160
|
+
export const LEGACY_UNICAST_TTL = 10;
|
|
161
|
+
|
|
162
|
+
/** 14 bits of pointer target, i.e. the largest offset a pointer can name. */
|
|
163
|
+
const MAX_POINTER_TARGET = 0x3fff;
|
|
164
|
+
|
|
165
|
+
/** Header size: ID, flags, and four section counts (RFC 1035 搂4.1.1). */
|
|
166
|
+
const HEADER_BYTES = 12;
|
|
167
|
+
|
|
168
|
+
/** Flag bits of the second header word (RFC 1035 搂4.1.1). */
|
|
169
|
+
export const FLAG = Object.freeze({
|
|
170
|
+
QR: 0x8000,
|
|
171
|
+
AA: 0x0400,
|
|
172
|
+
TC: 0x0200,
|
|
173
|
+
RD: 0x0100,
|
|
174
|
+
RA: 0x0080,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Every refusal this decoder can report, as a stable code.
|
|
179
|
+
*
|
|
180
|
+
* Codes are values, not exceptions: a caller can log "the LAN sent us 3 packets we refused, the last
|
|
181
|
+
* one because a compression pointer did not point backwards" without a stack trace and without a
|
|
182
|
+
* `try/catch` around a socket handler.
|
|
183
|
+
*/
|
|
184
|
+
export const REFUSAL = Object.freeze({
|
|
185
|
+
NOT_A_MESSAGE: 'MDNS_NOT_A_MESSAGE',
|
|
186
|
+
MESSAGE_TOO_LARGE: 'MDNS_MESSAGE_TOO_LARGE',
|
|
187
|
+
TRUNCATED: 'MDNS_TRUNCATED',
|
|
188
|
+
LABEL_TOO_LONG: 'MDNS_LABEL_TOO_LONG',
|
|
189
|
+
LABEL_TYPE: 'MDNS_LABEL_TYPE',
|
|
190
|
+
NAME_TOO_LONG: 'MDNS_NAME_TOO_LONG',
|
|
191
|
+
POINTER_LOOP: 'MDNS_POINTER_LOOP',
|
|
192
|
+
POINTER_HOPS: 'MDNS_POINTER_HOPS',
|
|
193
|
+
RECORD_COUNT: 'MDNS_RECORD_COUNT',
|
|
194
|
+
RDATA_LENGTH: 'MDNS_RDATA_LENGTH',
|
|
195
|
+
INTERNAL: 'MDNS_INTERNAL',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* An invalid *argument* to an encoder.
|
|
200
|
+
*
|
|
201
|
+
* The decoder never throws 鈥?it refuses. The encoder does, because its input is our own data: writing
|
|
202
|
+
* a packet we know to be malformed would put a lie on the wire where nobody is left to catch it.
|
|
203
|
+
*/
|
|
204
|
+
export class MdnsWireError extends Error {
|
|
205
|
+
/**
|
|
206
|
+
* @param {string} code Stable code, e.g. `MDNS_LABEL_TOO_LONG`.
|
|
207
|
+
* @param {string} message What was wrong and, when known, which value.
|
|
208
|
+
*/
|
|
209
|
+
constructor(code, message) {
|
|
210
|
+
super(`${code}: ${message}`);
|
|
211
|
+
this.name = 'MdnsWireError';
|
|
212
|
+
this.code = code;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A refusal that the caller can branch on. Never thrown; always returned. */
|
|
217
|
+
function refuse(reason, detail = undefined) {
|
|
218
|
+
return detail === undefined ? { ok: false, reason } : { ok: false, reason, detail };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------------------------
|
|
222
|
+
// Name and address helpers
|
|
223
|
+
// ---------------------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Lower-case a name and drop one trailing dot, for case-insensitive comparison.
|
|
227
|
+
*
|
|
228
|
+
* DNS names are case-insensitive (RFC 1035 搂2.3.3) but the wire case is preserved everywhere else in
|
|
229
|
+
* this module, because a DNS-SD *instance* name is a human-readable label and "Mac Studio" is not
|
|
230
|
+
* "mac studio" to the person who reads it.
|
|
231
|
+
*
|
|
232
|
+
* @param {unknown} name
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
export function normalizeName(name) {
|
|
236
|
+
const text = typeof name === 'string' ? name : String(name ?? '');
|
|
237
|
+
const trimmed = text.endsWith('.') ? text.slice(0, -1) : text;
|
|
238
|
+
return trimmed.toLowerCase();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Are these two names the same name, ignoring case and one trailing dot?
|
|
243
|
+
*
|
|
244
|
+
* @param {unknown} a
|
|
245
|
+
* @param {unknown} b
|
|
246
|
+
* @returns {boolean}
|
|
247
|
+
*/
|
|
248
|
+
export function nameEquals(a, b) {
|
|
249
|
+
return normalizeName(a) === normalizeName(b);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @param {number} type @returns {string} */
|
|
253
|
+
export function typeName(type) {
|
|
254
|
+
return TYPE_NAME[type] ?? `TYPE${Number.isInteger(type) ? type : '?'}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Split a dotted name into labels, refusing anything that cannot be encoded.
|
|
259
|
+
*
|
|
260
|
+
* Escape sequences (`\.`) are **not** parsed: a label containing a literal dot cannot be expressed by
|
|
261
|
+
* this codec. See the module header of `src/agent/mdns.mjs` for why that is acceptable here and what
|
|
262
|
+
* it costs.
|
|
263
|
+
*
|
|
264
|
+
* @param {unknown} name
|
|
265
|
+
* @param {string} [what] What is being encoded, for the error message.
|
|
266
|
+
* @returns {string[]} Labels, without the root label.
|
|
267
|
+
*/
|
|
268
|
+
export function splitName(name, what = 'name') {
|
|
269
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
270
|
+
throw new MdnsWireError(
|
|
271
|
+
'MDNS_NAME_EMPTY',
|
|
272
|
+
`${what} must be a non-empty string, got ${JSON.stringify(name)}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const labels = (name.endsWith('.') ? name.slice(0, -1) : name).split('.');
|
|
276
|
+
let encoded = 1; // the terminating zero octet
|
|
277
|
+
for (const label of labels) {
|
|
278
|
+
if (label === '') {
|
|
279
|
+
throw new MdnsWireError('MDNS_NAME_EMPTY_LABEL', `${what} ${JSON.stringify(name)} has an empty label`);
|
|
280
|
+
}
|
|
281
|
+
const bytes = Buffer.byteLength(label, 'utf8');
|
|
282
|
+
if (bytes > MAX_LABEL_BYTES) {
|
|
283
|
+
throw new MdnsWireError(
|
|
284
|
+
REFUSAL.LABEL_TOO_LONG,
|
|
285
|
+
`${what} ${JSON.stringify(name)} has a ${bytes}-octet label; RFC 1035 搂2.3.4 allows ${MAX_LABEL_BYTES}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
encoded += bytes + 1;
|
|
289
|
+
}
|
|
290
|
+
if (encoded > MAX_NAME_BYTES) {
|
|
291
|
+
throw new MdnsWireError(
|
|
292
|
+
REFUSAL.NAME_TOO_LONG,
|
|
293
|
+
`${what} ${JSON.stringify(name)} encodes to ${encoded} octets; RFC 1035 搂2.3.4 allows ${MAX_NAME_BYTES}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return labels;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `a.b.c.` 鈫?encoded name, with no compression (a standalone helper for tests and for RDATA that must
|
|
301
|
+
* not be compressed).
|
|
302
|
+
*
|
|
303
|
+
* @param {string} name
|
|
304
|
+
* @param {{what?: string}} [options]
|
|
305
|
+
* @returns {Buffer}
|
|
306
|
+
*/
|
|
307
|
+
export function encodeName(name, options = {}) {
|
|
308
|
+
const labels = splitName(name, options.what);
|
|
309
|
+
const parts = [];
|
|
310
|
+
for (const label of labels) {
|
|
311
|
+
const bytes = Buffer.from(label, 'utf8');
|
|
312
|
+
parts.push(Buffer.from([bytes.length]), bytes);
|
|
313
|
+
}
|
|
314
|
+
parts.push(Buffer.from([0]));
|
|
315
|
+
return Buffer.concat(parts);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `192.168.0.1` 鈫?4 octets. Strict: four decimal octets, each 0-255, no shorthand. */
|
|
319
|
+
export function parseIpv4(text) {
|
|
320
|
+
if (typeof text !== 'string')
|
|
321
|
+
throw new MdnsWireError('MDNS_A_INVALID', `IPv4 address must be a string, got ${typeof text}`);
|
|
322
|
+
const parts = text.trim().split('.');
|
|
323
|
+
if (parts.length !== 4)
|
|
324
|
+
throw new MdnsWireError('MDNS_A_INVALID', `${JSON.stringify(text)} is not four octets`);
|
|
325
|
+
const bytes = Buffer.alloc(4);
|
|
326
|
+
for (let i = 0; i < 4; i += 1) {
|
|
327
|
+
if (!/^\d{1,3}$/.test(parts[i]))
|
|
328
|
+
throw new MdnsWireError('MDNS_A_INVALID', `${JSON.stringify(text)} has a non-numeric octet`);
|
|
329
|
+
const value = Number(parts[i]);
|
|
330
|
+
if (value > 255)
|
|
331
|
+
throw new MdnsWireError('MDNS_A_INVALID', `${JSON.stringify(text)} has an octet above 255`);
|
|
332
|
+
bytes[i] = value;
|
|
333
|
+
}
|
|
334
|
+
return bytes;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 4 octets 鈫?dotted quad. */
|
|
338
|
+
export function formatIpv4(bytes) {
|
|
339
|
+
return `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* `2001:db8::1` 鈫?16 octets, with `::` expansion and an embedded IPv4 tail.
|
|
344
|
+
*
|
|
345
|
+
* Hand-written rather than borrowed: `node:net` validates an IPv6 literal but does not hand back its
|
|
346
|
+
* bytes, and the only other source of bytes would be a dependency this project does not take.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} text
|
|
349
|
+
* @returns {Buffer}
|
|
350
|
+
*/
|
|
351
|
+
export function parseIpv6(text) {
|
|
352
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
353
|
+
throw new MdnsWireError(
|
|
354
|
+
'MDNS_AAAA_INVALID',
|
|
355
|
+
`IPv6 address must be a non-empty string, got ${JSON.stringify(text)}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
let value = text.trim().toLowerCase();
|
|
359
|
+
if (value.includes('%'))
|
|
360
|
+
throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} carries a zone index, which is link-local only`);
|
|
361
|
+
if (value.includes('.')) {
|
|
362
|
+
// An embedded IPv4 tail (RFC 4291 搂2.2): convert it to two groups and re-parse.
|
|
363
|
+
const cut = value.lastIndexOf(':');
|
|
364
|
+
if (cut === -1)
|
|
365
|
+
throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has an IPv4 tail but no IPv6 prefix`);
|
|
366
|
+
const v4 = parseIpv4(value.slice(cut + 1));
|
|
367
|
+
value = `${value.slice(0, cut)}:${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
|
368
|
+
}
|
|
369
|
+
const halves = value.split('::');
|
|
370
|
+
if (halves.length > 2) throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has more than one "::"`);
|
|
371
|
+
const head = halves[0] === '' ? [] : halves[0].split(':');
|
|
372
|
+
const tail = halves.length === 2 ? (halves[1] === '' ? [] : halves[1].split(':')) : [];
|
|
373
|
+
const groups = [];
|
|
374
|
+
const push = (list) => {
|
|
375
|
+
for (const group of list) {
|
|
376
|
+
if (!/^[0-9a-f]{1,4}$/.test(group))
|
|
377
|
+
throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has a bad group ${JSON.stringify(group)}`);
|
|
378
|
+
groups.push(Number.parseInt(group, 16));
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
push(head);
|
|
382
|
+
const headCount = groups.length;
|
|
383
|
+
push(tail);
|
|
384
|
+
if (halves.length === 2) {
|
|
385
|
+
const missing = 8 - groups.length;
|
|
386
|
+
if (missing < 1)
|
|
387
|
+
throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} uses "::" but already has eight groups`);
|
|
388
|
+
groups.splice(headCount, 0, ...new Array(missing).fill(0));
|
|
389
|
+
}
|
|
390
|
+
if (groups.length !== 8)
|
|
391
|
+
throw new MdnsWireError('MDNS_AAAA_INVALID', `${text} has ${groups.length} groups, not eight`);
|
|
392
|
+
const bytes = Buffer.alloc(16);
|
|
393
|
+
groups.forEach((group, index) => bytes.writeUInt16BE(group, index * 2));
|
|
394
|
+
return bytes;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 16 octets 鈫?text, with the longest run of zero groups collapsed to `::` (RFC 5952 搂4.2).
|
|
399
|
+
*
|
|
400
|
+
* @param {Buffer} bytes
|
|
401
|
+
* @returns {string}
|
|
402
|
+
*/
|
|
403
|
+
export function formatIpv6(bytes) {
|
|
404
|
+
const groups = [];
|
|
405
|
+
for (let i = 0; i < 16; i += 2) groups.push(bytes.readUInt16BE(i));
|
|
406
|
+
let bestStart = -1;
|
|
407
|
+
let bestLength = 0;
|
|
408
|
+
let start = -1;
|
|
409
|
+
for (let i = 0; i <= 8; i += 1) {
|
|
410
|
+
const isZero = i < 8 && groups[i] === 0;
|
|
411
|
+
if (isZero && start === -1) start = i;
|
|
412
|
+
if (!isZero && start !== -1) {
|
|
413
|
+
const length = i - start;
|
|
414
|
+
if (length > bestLength) {
|
|
415
|
+
bestStart = start;
|
|
416
|
+
bestLength = length;
|
|
417
|
+
}
|
|
418
|
+
start = -1;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (bestLength < 2) return groups.map((group) => group.toString(16)).join(':');
|
|
422
|
+
const head = groups
|
|
423
|
+
.slice(0, bestStart)
|
|
424
|
+
.map((group) => group.toString(16))
|
|
425
|
+
.join(':');
|
|
426
|
+
const tail = groups
|
|
427
|
+
.slice(bestStart + bestLength)
|
|
428
|
+
.map((group) => group.toString(16))
|
|
429
|
+
.join(':');
|
|
430
|
+
return `${head}::${tail}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Does this IPv4 address look like something worth offering as a peer endpoint? */
|
|
434
|
+
export function isUsableUnicastIpv4(address) {
|
|
435
|
+
const octets = String(address).split('.').map(Number);
|
|
436
|
+
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255))
|
|
437
|
+
return false;
|
|
438
|
+
if (octets[0] === 0 || octets[0] === 127 || octets[0] >= 224) return false; // this-network, loopback, multicast/reserved
|
|
439
|
+
if (octets[0] === 255 && octets[1] === 255 && octets[2] === 255 && octets[3] === 255) return false;
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------------------------
|
|
444
|
+
// Decoding
|
|
445
|
+
// ---------------------------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Read one (possibly compressed) name, with every guard the header documents.
|
|
449
|
+
*
|
|
450
|
+
* @param {Buffer} buffer Whole message; pointers are absolute offsets into it.
|
|
451
|
+
* @param {number} start Offset of the first length octet.
|
|
452
|
+
* @returns {{ok: true, name: string, next: number, hops: number, bytes: number}|{ok: false, reason: string, detail?: object}}
|
|
453
|
+
* `hops` is the number of compression pointers followed, which the caller folds into its counters.
|
|
454
|
+
*/
|
|
455
|
+
function readName(buffer, start) {
|
|
456
|
+
const labels = [];
|
|
457
|
+
let position = start;
|
|
458
|
+
/** Offset just past the name in the *original* stream, which a pointer fixes once and for all. */
|
|
459
|
+
let next = null;
|
|
460
|
+
let hops = 0;
|
|
461
|
+
let expanded = 1; // the terminating zero octet counts towards the 255 (RFC 1035 搂2.3.4)
|
|
462
|
+
|
|
463
|
+
for (;;) {
|
|
464
|
+
if (position >= buffer.length) return refuse(REFUSAL.TRUNCATED, { at: position, reading: 'name' });
|
|
465
|
+
const lengthByte = buffer[position];
|
|
466
|
+
|
|
467
|
+
if ((lengthByte & LABEL_KIND_MASK) === LABEL_KIND_MASK) {
|
|
468
|
+
if (position + 2 > buffer.length)
|
|
469
|
+
return refuse(REFUSAL.TRUNCATED, { at: position, reading: 'pointer' });
|
|
470
|
+
const target = ((lengthByte & 0x3f) << 8) | buffer[position + 1];
|
|
471
|
+
if (next === null) next = position + 2;
|
|
472
|
+
// The hop budget is checked before the backwards rule so that a *deep chain* of legal pointers
|
|
473
|
+
// is refused as a budget overrun rather than being mistaken for a loop.
|
|
474
|
+
if (hops >= MAX_POINTER_HOPS)
|
|
475
|
+
return refuse(REFUSAL.POINTER_HOPS, { hops, target, limit: MAX_POINTER_HOPS });
|
|
476
|
+
// RFC 1035 搂4.1.4: a pointer names a *prior* occurrence. A pointer that does not go strictly
|
|
477
|
+
// backwards is the only way to build a cycle, so it is refused as one. This is what makes
|
|
478
|
+
// termination a property of the format rather than of the hop counter.
|
|
479
|
+
if (target >= position) return refuse(REFUSAL.POINTER_LOOP, { target, position });
|
|
480
|
+
hops += 1;
|
|
481
|
+
position = target;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// `01` and `10` are reserved label kinds (RFC 1035 搂4.1.4 defines only `00` and `11`). A packet
|
|
486
|
+
// using them is not something to guess at.
|
|
487
|
+
if ((lengthByte & LABEL_KIND_MASK) !== 0)
|
|
488
|
+
return refuse(REFUSAL.LABEL_TYPE, { octet: lengthByte, at: position });
|
|
489
|
+
// Belt and braces: with the top two bits clear, `lengthByte` cannot exceed 63, so this branch is
|
|
490
|
+
// unreachable today. It stays because the 63-octet label limit is a rule this decoder claims to
|
|
491
|
+
// enforce, and a future edit that relaxes the mask above must not be able to relax this silently.
|
|
492
|
+
if (lengthByte > MAX_LABEL_BYTES)
|
|
493
|
+
return refuse(REFUSAL.LABEL_TOO_LONG, { labelBytes: lengthByte, at: position });
|
|
494
|
+
if (lengthByte === 0) {
|
|
495
|
+
if (next === null) next = position + 1;
|
|
496
|
+
return { ok: true, name: labels.join('.'), next, hops, bytes: expanded };
|
|
497
|
+
}
|
|
498
|
+
if (position + 1 + lengthByte > buffer.length) {
|
|
499
|
+
return refuse(REFUSAL.TRUNCATED, { at: position, labelBytes: lengthByte, reading: 'label' });
|
|
500
|
+
}
|
|
501
|
+
expanded += lengthByte + 1;
|
|
502
|
+
if (expanded > MAX_NAME_BYTES) {
|
|
503
|
+
return refuse(REFUSAL.NAME_TOO_LONG, { bytes: expanded, limit: MAX_NAME_BYTES, at: position });
|
|
504
|
+
}
|
|
505
|
+
labels.push(buffer.toString('utf8', position + 1, position + 1 + lengthByte));
|
|
506
|
+
position += 1 + lengthByte;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Decode the RDATA of one record, by type.
|
|
512
|
+
*
|
|
513
|
+
* The caller has already checked that `rdata` is exactly `rdlength` octets and inside the buffer, so
|
|
514
|
+
* every read here is bounded by construction; a *length* that does not match the type is still a
|
|
515
|
+
* refusal, because a record we cannot read exactly is a record we must not report.
|
|
516
|
+
*
|
|
517
|
+
* @param {Buffer} buffer Whole message, for names inside RDATA.
|
|
518
|
+
* @param {number} rdataStart
|
|
519
|
+
* @param {number} rdataEnd
|
|
520
|
+
* @param {number} type
|
|
521
|
+
* @returns {{ok: true, value: object}|{ok: false, reason: string, detail?: object}}
|
|
522
|
+
*/
|
|
523
|
+
function readRdata(buffer, rdataStart, rdataEnd, type) {
|
|
524
|
+
const length = rdataEnd - rdataStart;
|
|
525
|
+
if (type === RECORD_TYPE.A) {
|
|
526
|
+
if (length !== 4) return refuse(REFUSAL.RDATA_LENGTH, { type: 'A', length, expected: 4 });
|
|
527
|
+
const bytes = buffer.subarray(rdataStart, rdataEnd);
|
|
528
|
+
return { ok: true, value: { address: formatIpv4(bytes) } };
|
|
529
|
+
}
|
|
530
|
+
if (type === RECORD_TYPE.AAAA) {
|
|
531
|
+
if (length !== 16) return refuse(REFUSAL.RDATA_LENGTH, { type: 'AAAA', length, expected: 16 });
|
|
532
|
+
return { ok: true, value: { address: formatIpv6(buffer.subarray(rdataStart, rdataEnd)) } };
|
|
533
|
+
}
|
|
534
|
+
if (type === RECORD_TYPE.PTR) {
|
|
535
|
+
if (length === 0) return refuse(REFUSAL.RDATA_LENGTH, { type: 'PTR', length, expected: 'a name' });
|
|
536
|
+
const name = readName(buffer, rdataStart);
|
|
537
|
+
if (!name.ok) return name;
|
|
538
|
+
// RFC 1035 搂4.1.4 permits PTR RDATA to be compressed, and mDNS implementations do it, so a
|
|
539
|
+
// pointer is followed here (leniently) even though the encoder writes this name uncompressed.
|
|
540
|
+
return { ok: true, value: { target: name.name } };
|
|
541
|
+
}
|
|
542
|
+
if (type === RECORD_TYPE.SRV) {
|
|
543
|
+
if (length < 6) return refuse(REFUSAL.RDATA_LENGTH, { type: 'SRV', length, expected: 'at least 6' });
|
|
544
|
+
const target = readName(buffer, rdataStart + 6);
|
|
545
|
+
if (!target.ok) return target;
|
|
546
|
+
if (target.next > rdataEnd)
|
|
547
|
+
return refuse(REFUSAL.RDATA_LENGTH, { type: 'SRV', length, detail: 'target name runs past the rdata' });
|
|
548
|
+
return {
|
|
549
|
+
ok: true,
|
|
550
|
+
value: {
|
|
551
|
+
priority: buffer.readUInt16BE(rdataStart),
|
|
552
|
+
weight: buffer.readUInt16BE(rdataStart + 2),
|
|
553
|
+
port: buffer.readUInt16BE(rdataStart + 4),
|
|
554
|
+
target: target.name,
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (type === RECORD_TYPE.TXT) {
|
|
559
|
+
const strings = [];
|
|
560
|
+
let position = rdataStart;
|
|
561
|
+
while (position < rdataEnd) {
|
|
562
|
+
const stringLength = buffer[position];
|
|
563
|
+
position += 1;
|
|
564
|
+
if (position + stringLength > rdataEnd)
|
|
565
|
+
return refuse(REFUSAL.RDATA_LENGTH, { type: 'TXT', length, at: position });
|
|
566
|
+
strings.push(buffer.toString('utf8', position, position + stringLength));
|
|
567
|
+
position += stringLength;
|
|
568
|
+
}
|
|
569
|
+
return { ok: true, value: { txt: strings } };
|
|
570
|
+
}
|
|
571
|
+
return { ok: true, value: {} };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Read one resource record.
|
|
576
|
+
*
|
|
577
|
+
* @param {Buffer} buffer
|
|
578
|
+
* @param {number} offset Offset of the record's owner name.
|
|
579
|
+
* @param {string} section `answer` | `authority` | `additional`.
|
|
580
|
+
* @param {{stats: object}} context
|
|
581
|
+
* @returns {{ok: true, record: object, next: number}|{ok: false, reason: string, detail?: object}}
|
|
582
|
+
*/
|
|
583
|
+
function readRecord(buffer, offset, section, context) {
|
|
584
|
+
const name = readName(buffer, offset);
|
|
585
|
+
if (!name.ok) return name;
|
|
586
|
+
const fields = name.next;
|
|
587
|
+
// NAME + TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = the 10 octets every record must have.
|
|
588
|
+
if (fields + 10 > buffer.length)
|
|
589
|
+
return refuse(REFUSAL.TRUNCATED, { at: fields, reading: `${section} record header` });
|
|
590
|
+
const type = buffer.readUInt16BE(fields);
|
|
591
|
+
const rrclass = buffer.readUInt16BE(fields + 2);
|
|
592
|
+
const ttl = buffer.readUInt32BE(fields + 4);
|
|
593
|
+
const rdlength = buffer.readUInt16BE(fields + 8);
|
|
594
|
+
const rdataStart = fields + 10;
|
|
595
|
+
const rdataEnd = rdataStart + rdlength;
|
|
596
|
+
if (rdataEnd > buffer.length) {
|
|
597
|
+
return refuse(REFUSAL.TRUNCATED, { at: rdataStart, rdlength, available: buffer.length - rdataStart });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const value = readRdata(buffer, rdataStart, rdataEnd, type);
|
|
601
|
+
if (!value.ok) return value;
|
|
602
|
+
|
|
603
|
+
context.stats.records_read += 1;
|
|
604
|
+
context.stats.names_read += 1;
|
|
605
|
+
if (name.hops > context.stats.pointer_hops_max) context.stats.pointer_hops_max = name.hops;
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
next: rdataEnd,
|
|
609
|
+
record: {
|
|
610
|
+
section,
|
|
611
|
+
name: name.name,
|
|
612
|
+
type,
|
|
613
|
+
type_name: typeName(type),
|
|
614
|
+
// RFC 6762 搂10.2: the cache-flush bit is not part of the class and must be masked off.
|
|
615
|
+
class: rrclass & 0x7fff,
|
|
616
|
+
cache_flush: (rrclass & CACHE_FLUSH) !== 0,
|
|
617
|
+
ttl,
|
|
618
|
+
// TTL 0 is a goodbye (RFC 6762 搂10.1), and it is a *value*, not an error: the consumers drop
|
|
619
|
+
// the record when they see it.
|
|
620
|
+
goodbye: ttl === 0,
|
|
621
|
+
rdata: Buffer.from(buffer.subarray(rdataStart, rdataEnd)),
|
|
622
|
+
offset,
|
|
623
|
+
...value.value,
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Decode one DNS/mDNS message.
|
|
630
|
+
*
|
|
631
|
+
* Never throws. A malformed, hostile or truncated packet is a refusal with a stable code.
|
|
632
|
+
*
|
|
633
|
+
* @param {unknown} buffer
|
|
634
|
+
* @returns {{ok: true, message: object}|{ok: false, reason: string, detail?: object}}
|
|
635
|
+
*/
|
|
636
|
+
export function decodeMessage(buffer) {
|
|
637
|
+
try {
|
|
638
|
+
if (!Buffer.isBuffer(buffer)) return refuse(REFUSAL.NOT_A_MESSAGE, { got: typeof buffer });
|
|
639
|
+
if (buffer.length < HEADER_BYTES)
|
|
640
|
+
return refuse(REFUSAL.TRUNCATED, { bytes: buffer.length, reading: 'header' });
|
|
641
|
+
if (buffer.length > MAX_MESSAGE_BYTES)
|
|
642
|
+
return refuse(REFUSAL.MESSAGE_TOO_LARGE, { bytes: buffer.length, limit: MAX_MESSAGE_BYTES });
|
|
643
|
+
|
|
644
|
+
const id = buffer.readUInt16BE(0);
|
|
645
|
+
const flagsWord = buffer.readUInt16BE(2);
|
|
646
|
+
const counts = {
|
|
647
|
+
questions: buffer.readUInt16BE(4),
|
|
648
|
+
answers: buffer.readUInt16BE(6),
|
|
649
|
+
authorities: buffer.readUInt16BE(8),
|
|
650
|
+
additionals: buffer.readUInt16BE(10),
|
|
651
|
+
};
|
|
652
|
+
const total = counts.questions + counts.answers + counts.authorities + counts.additionals;
|
|
653
|
+
if (total > MAX_RECORDS) return refuse(REFUSAL.RECORD_COUNT, { total, limit: MAX_RECORDS });
|
|
654
|
+
|
|
655
|
+
const context = { stats: { names_read: 0, records_read: 0, pointer_hops_max: 0 } };
|
|
656
|
+
let offset = HEADER_BYTES;
|
|
657
|
+
const questions = [];
|
|
658
|
+
|
|
659
|
+
for (let index = 0; index < counts.questions; index += 1) {
|
|
660
|
+
const name = readName(buffer, offset);
|
|
661
|
+
if (!name.ok) return name;
|
|
662
|
+
offset = name.next;
|
|
663
|
+
if (offset + 4 > buffer.length) return refuse(REFUSAL.TRUNCATED, { at: offset, reading: 'question' });
|
|
664
|
+
const qclass = buffer.readUInt16BE(offset + 2);
|
|
665
|
+
questions.push({
|
|
666
|
+
name: name.name,
|
|
667
|
+
type: buffer.readUInt16BE(offset),
|
|
668
|
+
class: qclass & 0x7fff,
|
|
669
|
+
// RFC 6762 搂5.4: the top bit of the class asks for a unicast reply ("QU").
|
|
670
|
+
unicast_response: (qclass & UNICAST_RESPONSE) !== 0,
|
|
671
|
+
});
|
|
672
|
+
offset += 4;
|
|
673
|
+
context.stats.names_read += 1;
|
|
674
|
+
if (name.hops > context.stats.pointer_hops_max) context.stats.pointer_hops_max = name.hops;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const sections = [
|
|
678
|
+
['answer', counts.answers],
|
|
679
|
+
['authority', counts.authorities],
|
|
680
|
+
['additional', counts.additionals],
|
|
681
|
+
];
|
|
682
|
+
const records = [];
|
|
683
|
+
for (const [section, count] of sections) {
|
|
684
|
+
for (let index = 0; index < count; index += 1) {
|
|
685
|
+
const read = readRecord(buffer, offset, section, context);
|
|
686
|
+
if (!read.ok) return read;
|
|
687
|
+
offset = read.next;
|
|
688
|
+
records.push(read.record);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
ok: true,
|
|
694
|
+
message: {
|
|
695
|
+
id,
|
|
696
|
+
flags: {
|
|
697
|
+
qr: (flagsWord & FLAG.QR) !== 0,
|
|
698
|
+
opcode: (flagsWord >> 11) & 0x0f,
|
|
699
|
+
aa: (flagsWord & FLAG.AA) !== 0,
|
|
700
|
+
tc: (flagsWord & FLAG.TC) !== 0,
|
|
701
|
+
rd: (flagsWord & FLAG.RD) !== 0,
|
|
702
|
+
ra: (flagsWord & FLAG.RA) !== 0,
|
|
703
|
+
rcode: flagsWord & 0x0f,
|
|
704
|
+
word: flagsWord,
|
|
705
|
+
},
|
|
706
|
+
counts,
|
|
707
|
+
questions,
|
|
708
|
+
answers: records.filter((record) => record.section === 'answer'),
|
|
709
|
+
authorities: records.filter((record) => record.section === 'authority'),
|
|
710
|
+
additionals: records.filter((record) => record.section === 'additional'),
|
|
711
|
+
records,
|
|
712
|
+
// Bytes after the last record. Non-zero means the sender wrote something this parser does not
|
|
713
|
+
// account for; it is reported rather than hidden, and never treated as a record.
|
|
714
|
+
trailing_bytes: buffer.length - offset,
|
|
715
|
+
stats: context.stats,
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
} catch (error) {
|
|
719
|
+
// The whole point of this decoder is that nothing escapes it. A bug below becomes a refusal.
|
|
720
|
+
return refuse(REFUSAL.INTERNAL, { message: error instanceof Error ? error.message : String(error) });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* {@link decodeMessage}, flattened to the message or `null`.
|
|
726
|
+
*
|
|
727
|
+
* @param {unknown} buffer
|
|
728
|
+
* @returns {object|null}
|
|
729
|
+
*/
|
|
730
|
+
export function decodeMessageOrNull(buffer) {
|
|
731
|
+
const decoded = decodeMessage(buffer);
|
|
732
|
+
return decoded.ok ? decoded.message : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ---------------------------------------------------------------------------------------------
|
|
736
|
+
// Encoding
|
|
737
|
+
// ---------------------------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A message writer with a per-message name-compression suffix dictionary.
|
|
741
|
+
*
|
|
742
|
+
* Offsets are recorded for every suffix written at an offset a pointer could name (`<= 0x3fff`), which
|
|
743
|
+
* is what makes the encoder compress the *owner names* and the PTR RDATA the way an mDNS peer expects
|
|
744
|
+
* to receive them.
|
|
745
|
+
*
|
|
746
|
+
* @returns {object}
|
|
747
|
+
*/
|
|
748
|
+
function createWriter() {
|
|
749
|
+
const parts = [];
|
|
750
|
+
const suffixes = new Map();
|
|
751
|
+
let length = 0;
|
|
752
|
+
|
|
753
|
+
const push = (buffer) => {
|
|
754
|
+
parts.push(buffer);
|
|
755
|
+
length += buffer.length;
|
|
756
|
+
return buffer;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
get length() {
|
|
761
|
+
return length;
|
|
762
|
+
},
|
|
763
|
+
writeUint8: (value) => push(Buffer.from([value & 0xff])),
|
|
764
|
+
writeUint16: (value) => {
|
|
765
|
+
const buffer = Buffer.alloc(2);
|
|
766
|
+
buffer.writeUInt16BE(value & 0xffff, 0);
|
|
767
|
+
return push(buffer);
|
|
768
|
+
},
|
|
769
|
+
writeUint32: (value) => {
|
|
770
|
+
const buffer = Buffer.alloc(4);
|
|
771
|
+
buffer.writeUInt32BE(value >>> 0, 0);
|
|
772
|
+
return push(buffer);
|
|
773
|
+
},
|
|
774
|
+
writeBytes: (buffer) => push(Buffer.from(buffer)),
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Write one name, compressing from the dictionary when asked.
|
|
778
|
+
*
|
|
779
|
+
* @param {string} name
|
|
780
|
+
* @param {{compress?: boolean, remember?: boolean, what?: string}} [options] `compress: false`
|
|
781
|
+
* writes every label (SRV RDATA must not be compressed); `remember: false` keeps the offsets out
|
|
782
|
+
* of the dictionary, so no later record can point *into* this name.
|
|
783
|
+
*/
|
|
784
|
+
writeName(name, options = {}) {
|
|
785
|
+
const compress = options.compress !== false;
|
|
786
|
+
const remember = compress && options.remember !== false;
|
|
787
|
+
const labels = splitName(name, options.what);
|
|
788
|
+
for (let index = 0; index < labels.length; index += 1) {
|
|
789
|
+
const suffix = labels.slice(index).join('.').toLowerCase();
|
|
790
|
+
const known = compress ? suffixes.get(suffix) : undefined;
|
|
791
|
+
if (known !== undefined && known <= MAX_POINTER_TARGET) {
|
|
792
|
+
this.writeUint16(POINTER_WORD_MASK | known);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (remember && length <= MAX_POINTER_TARGET && suffixes.size < MAX_COMPRESSION_ENTRIES) {
|
|
796
|
+
suffixes.set(suffix, length);
|
|
797
|
+
}
|
|
798
|
+
const bytes = Buffer.from(labels[index], 'utf8');
|
|
799
|
+
push(Buffer.from([bytes.length]));
|
|
800
|
+
push(bytes);
|
|
801
|
+
}
|
|
802
|
+
push(Buffer.from([0]));
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
/** Write a length placeholder and hand back the buffer, so the caller can patch it. */
|
|
806
|
+
reserveUint16() {
|
|
807
|
+
return push(Buffer.alloc(2));
|
|
808
|
+
},
|
|
809
|
+
|
|
810
|
+
/** @param {Buffer} placeholder @param {number} value */
|
|
811
|
+
patchUint16(placeholder, value) {
|
|
812
|
+
placeholder.writeUInt16BE(value & 0xffff, 0);
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
toBuffer: () => Buffer.concat(parts, length),
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Encode the RDATA of one record, either into the message or into a standalone buffer.
|
|
821
|
+
*
|
|
822
|
+
* Compression policy, and the reasoning behind it:
|
|
823
|
+
*
|
|
824
|
+
* * **PTR RDATA is compressed.** It is a name, RFC 1035 搂4.1.4 permits it, and every mDNS responder
|
|
825
|
+
* on a real LAN does it 鈥?a response that did not would be larger than the peers' own.
|
|
826
|
+
* * **SRV RDATA is not compressed.** RFC 1035 搂4.1.4 lists the types whose RDATA may be compressed
|
|
827
|
+
* and SRV (RFC 2782) is not among them, which is the conservative reading every interoperating
|
|
828
|
+
* implementation follows. The *decoder* still follows a pointer there, because refusing a packet a
|
|
829
|
+
* peer legitimately built is worse than accepting one written in a style we would not choose.
|
|
830
|
+
*
|
|
831
|
+
* Two shapes come back, and the difference is not cosmetic: a name inside RDATA can only be compressed
|
|
832
|
+
* against offsets that exist in the *message*, so PTR and SRV RDATA must be written straight into it
|
|
833
|
+
* (`{inline: true}`) and A/AAAA/TXT/raw RDATA are built separately and appended.
|
|
834
|
+
*
|
|
835
|
+
* @param {object} writer
|
|
836
|
+
* @param {object} record
|
|
837
|
+
* @returns {{inline: true}|{bytes: Buffer}}
|
|
838
|
+
*/
|
|
839
|
+
function encodeRdata(writer, record) {
|
|
840
|
+
const type = record.type;
|
|
841
|
+
if (type === RECORD_TYPE.A) return { bytes: parseIpv4(record.address) };
|
|
842
|
+
if (type === RECORD_TYPE.AAAA) return { bytes: parseIpv6(record.address) };
|
|
843
|
+
if (type === RECORD_TYPE.PTR) {
|
|
844
|
+
writer.writeName(record.target, { what: 'PTR target' });
|
|
845
|
+
return { inline: true };
|
|
846
|
+
}
|
|
847
|
+
if (type === RECORD_TYPE.SRV) {
|
|
848
|
+
writer.writeUint16(record.priority ?? 0);
|
|
849
|
+
writer.writeUint16(record.weight ?? 0);
|
|
850
|
+
writer.writeUint16(record.port ?? 0);
|
|
851
|
+
writer.writeName(record.target, { compress: false, remember: false, what: 'SRV target' });
|
|
852
|
+
return { inline: true };
|
|
853
|
+
}
|
|
854
|
+
if (type === RECORD_TYPE.TXT) return { bytes: encodeTxt(record) };
|
|
855
|
+
if (Buffer.isBuffer(record.rdata)) return { bytes: record.rdata };
|
|
856
|
+
throw new MdnsWireError(
|
|
857
|
+
'MDNS_RDATA_UNSUPPORTED',
|
|
858
|
+
`no encoder for ${typeName(type)} and no raw \`rdata\` was given`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Encode a TXT RDATA: a sequence of length-prefixed character-strings, each at most 255 octets.
|
|
864
|
+
*
|
|
865
|
+
* An empty list is written as one zero-length string, which is the DNS-SD convention for "this service
|
|
866
|
+
* has no attributes but does have a TXT record" (RFC 6763 搂6.1).
|
|
867
|
+
*
|
|
868
|
+
* @param {object} record
|
|
869
|
+
* @returns {Buffer}
|
|
870
|
+
*/
|
|
871
|
+
function encodeTxt(record) {
|
|
872
|
+
const strings = Array.isArray(record.txt) ? record.txt.map((entry) => String(entry)) : [];
|
|
873
|
+
const parts = [];
|
|
874
|
+
for (const text of strings) {
|
|
875
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
876
|
+
if (bytes.length > 255)
|
|
877
|
+
throw new MdnsWireError(
|
|
878
|
+
'MDNS_TXT_TOO_LONG',
|
|
879
|
+
`TXT string of ${bytes.length} octets; RFC 1035 搂3.3.14 allows 255`,
|
|
880
|
+
);
|
|
881
|
+
parts.push(Buffer.from([bytes.length]), bytes);
|
|
882
|
+
}
|
|
883
|
+
if (parts.length === 0) parts.push(Buffer.from([0]));
|
|
884
|
+
return Buffer.concat(parts);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Build the second header word from a flags object.
|
|
889
|
+
*/
|
|
890
|
+
function encodeFlags(flags = {}) {
|
|
891
|
+
let word = 0;
|
|
892
|
+
if (flags.qr === true) word |= FLAG.QR;
|
|
893
|
+
if (flags.aa === true) word |= FLAG.AA;
|
|
894
|
+
if (flags.tc === true) word |= FLAG.TC;
|
|
895
|
+
if (flags.rd === true) word |= FLAG.RD;
|
|
896
|
+
if (flags.ra === true) word |= FLAG.RA;
|
|
897
|
+
const opcode = Number.isInteger(flags.opcode) ? flags.opcode : 0;
|
|
898
|
+
const rcode = Number.isInteger(flags.rcode) ? flags.rcode : 0;
|
|
899
|
+
return word | ((opcode & 0x0f) << 11) | (rcode & 0x0f);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Write one record into a message.
|
|
904
|
+
*
|
|
905
|
+
* @param {object} writer
|
|
906
|
+
* @param {object} record
|
|
907
|
+
* @param {string} section Section name, for diagnostics on a refusal.
|
|
908
|
+
* @returns {void}
|
|
909
|
+
*/
|
|
910
|
+
function writeRecord(writer, record, section) {
|
|
911
|
+
const type = typeof record.type === 'number' ? record.type : RECORD_TYPE[String(record.type).toUpperCase()];
|
|
912
|
+
if (!Number.isInteger(type)) {
|
|
913
|
+
throw new MdnsWireError(
|
|
914
|
+
'MDNS_TYPE_UNKNOWN',
|
|
915
|
+
`${section} record has no usable type: ${JSON.stringify(record.type)}`,
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
const rrclass = Number.isInteger(record.class) ? record.class : DNS_CLASS_IN;
|
|
919
|
+
const ttl = Number.isInteger(record.ttl) ? record.ttl : 0;
|
|
920
|
+
if (ttl < 0 || ttl > 0xffffffff)
|
|
921
|
+
throw new MdnsWireError('MDNS_TTL_RANGE', `ttl ${ttl} is outside 0..4294967295`);
|
|
922
|
+
writer.writeName(record.name, { what: `${section} record owner` });
|
|
923
|
+
writer.writeUint16(type);
|
|
924
|
+
writer.writeUint16(rrclass | (record.cache_flush === true || record.cacheFlush === true ? CACHE_FLUSH : 0));
|
|
925
|
+
writer.writeUint32(ttl);
|
|
926
|
+
// The length has to precede the RDATA, but a compressed name inside the RDATA needs the offsets of
|
|
927
|
+
// everything before it 鈥?so the RDATA is written into the message and the placeholder patched after.
|
|
928
|
+
const placeholder = writer.reserveUint16();
|
|
929
|
+
const before = writer.length;
|
|
930
|
+
const rdata = encodeRdata(writer, { ...record, type });
|
|
931
|
+
if (rdata.bytes !== undefined) writer.writeBytes(rdata.bytes);
|
|
932
|
+
const length = writer.length - before;
|
|
933
|
+
if (length > 0xffff)
|
|
934
|
+
throw new MdnsWireError('MDNS_RDATA_TOO_LONG', `${section} record rdata is ${length} octets`);
|
|
935
|
+
if (rdata.bytes !== undefined && rdata.bytes.length !== length) {
|
|
936
|
+
throw new MdnsWireError(
|
|
937
|
+
'MDNS_RDATA_MISCOUNT',
|
|
938
|
+
`built ${rdata.bytes.length} octets of rdata but wrote ${length}`,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
writer.patchUint16(placeholder, length);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Encode one DNS/mDNS message.
|
|
946
|
+
*
|
|
947
|
+
* @param {object} message `{id, flags, questions, answers, authorities, additionals}`.
|
|
948
|
+
* @returns {Buffer}
|
|
949
|
+
* @throws {MdnsWireError} When an argument cannot be encoded. Encoder input is our own data.
|
|
950
|
+
*/
|
|
951
|
+
export function encodeMessage(message = {}) {
|
|
952
|
+
const writer = createWriter();
|
|
953
|
+
const questions = Array.isArray(message.questions) ? message.questions : [];
|
|
954
|
+
const sections = [
|
|
955
|
+
['answer', Array.isArray(message.answers) ? message.answers : []],
|
|
956
|
+
['authority', Array.isArray(message.authorities) ? message.authorities : []],
|
|
957
|
+
['additional', Array.isArray(message.additionals) ? message.additionals : []],
|
|
958
|
+
];
|
|
959
|
+
writer.writeUint16(Number.isInteger(message.id) ? message.id : 0);
|
|
960
|
+
writer.writeUint16(encodeFlags(message.flags));
|
|
961
|
+
writer.writeUint16(questions.length);
|
|
962
|
+
for (const [, records] of sections) writer.writeUint16(records.length);
|
|
963
|
+
|
|
964
|
+
for (const question of questions) {
|
|
965
|
+
writer.writeName(question.name, { what: 'question name' });
|
|
966
|
+
writer.writeUint16(question.type ?? RECORD_TYPE.ANY);
|
|
967
|
+
const qclass = Number.isInteger(question.class) ? question.class : DNS_CLASS_IN;
|
|
968
|
+
writer.writeUint16(qclass | (question.unicast_response === true ? UNICAST_RESPONSE : 0));
|
|
969
|
+
}
|
|
970
|
+
for (const [section, records] of sections) {
|
|
971
|
+
for (const record of records) writeRecord(writer, record, section);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const bytes = writer.toBuffer();
|
|
975
|
+
if (bytes.length > MAX_MESSAGE_BYTES) {
|
|
976
|
+
throw new MdnsWireError(
|
|
977
|
+
REFUSAL.MESSAGE_TOO_LARGE,
|
|
978
|
+
`${bytes.length} octets; the cap is ${MAX_MESSAGE_BYTES}`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
return bytes;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Build a service-discovery query.
|
|
986
|
+
*
|
|
987
|
+
* RFC 6762 搂5.1: a one-shot query MUST NOT be sent from source port 5353, because that port signals a
|
|
988
|
+
* fully compliant querier. The caller owns the socket and therefore the source port; this function
|
|
989
|
+
* only writes the message. RFC 6762 搂18.1: the ID of a multicast query is zero.
|
|
990
|
+
*
|
|
991
|
+
* @param {{serviceType: string, type?: number, unicastResponse?: boolean, id?: number}} options
|
|
992
|
+
* @returns {Buffer}
|
|
993
|
+
*/
|
|
994
|
+
export function encodeQuery(options) {
|
|
995
|
+
const type = options.type ?? RECORD_TYPE.PTR;
|
|
996
|
+
return encodeMessage({
|
|
997
|
+
id: Number.isInteger(options.id) ? options.id : 0,
|
|
998
|
+
// 搂18.3/搂18.4/搂18.5/搂18.6: opcode 0, and the RD/RA/Z bits clear on transmission.
|
|
999
|
+
flags: { qr: false, opcode: 0, aa: false, tc: false, rd: false, ra: false, rcode: 0 },
|
|
1000
|
+
questions: [
|
|
1001
|
+
{
|
|
1002
|
+
name: options.serviceType,
|
|
1003
|
+
type,
|
|
1004
|
+
class: DNS_CLASS_IN,
|
|
1005
|
+
unicast_response: options.unicastResponse === true,
|
|
1006
|
+
},
|
|
1007
|
+
],
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Build a response to a question, copying the question section.
|
|
1013
|
+
*
|
|
1014
|
+
* RFC 6762 搂6.7: a response to a query from a source port other than 5353 ("legacy") MUST repeat the
|
|
1015
|
+
* query ID and the question. `legacy` therefore carries those two behaviours and the 10-second TTL cap
|
|
1016
|
+
* (see {@link capLegacyTtl}).
|
|
1017
|
+
*
|
|
1018
|
+
* @param {object} options
|
|
1019
|
+
* @param {object} options.query The decoded query.
|
|
1020
|
+
* @param {object[]} options.answers
|
|
1021
|
+
* @param {object[]} [options.additionals]
|
|
1022
|
+
* @param {boolean} [options.legacy]
|
|
1023
|
+
* @returns {Buffer}
|
|
1024
|
+
*/
|
|
1025
|
+
export function encodeResponse(options) {
|
|
1026
|
+
const { query, answers = [], additionals = [] } = options;
|
|
1027
|
+
const legacy = options.legacy === true;
|
|
1028
|
+
return encodeMessage({
|
|
1029
|
+
id: legacy && Number.isInteger(query?.id) ? query.id : 0,
|
|
1030
|
+
// 搂18.7: the AA bit is set on responses; there is no recursion and no error code in mDNS.
|
|
1031
|
+
flags: { qr: true, opcode: 0, aa: true, tc: false, rd: false, ra: false, rcode: 0 },
|
|
1032
|
+
questions:
|
|
1033
|
+
legacy && Array.isArray(query?.questions)
|
|
1034
|
+
? query.questions.map((question) => ({ ...question, unicast_response: false }))
|
|
1035
|
+
: [],
|
|
1036
|
+
answers: answers.map((record) => withTtl(record, legacy)),
|
|
1037
|
+
// The additional section carries the records a peer needs next (SRV and TXT for a PTR answer, the
|
|
1038
|
+
// A record for an SRV answer). 搂10.2's cache-flush bit is *not* set for a legacy unicast response.
|
|
1039
|
+
additionals: additionals.map((record) => withTtl(record, legacy)),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Apply RFC 6762 搂6.7's TTL cap and cache-flush rule to one record of a response.
|
|
1045
|
+
*
|
|
1046
|
+
* A legacy resolver is a plain DNS client with no cache-coherency protocol, so it must not be handed a
|
|
1047
|
+
* 75-minute TTL: "the resource record TTL given in a legacy unicast response SHOULD NOT be greater
|
|
1048
|
+
* than ten seconds". The cache-flush bit is meaningless there and MUST NOT be set.
|
|
1049
|
+
*
|
|
1050
|
+
* @param {object} record
|
|
1051
|
+
* @param {boolean} legacy
|
|
1052
|
+
* @returns {object}
|
|
1053
|
+
*/
|
|
1054
|
+
export function capLegacyTtl(record, legacy) {
|
|
1055
|
+
return withTtl(record, legacy);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** @param {object} record @param {boolean} legacy @returns {object} */
|
|
1059
|
+
function withTtl(record, legacy) {
|
|
1060
|
+
if (!legacy) return record;
|
|
1061
|
+
return { ...record, ttl: Math.min(Number(record.ttl) || 0, LEGACY_UNICAST_TTL), cache_flush: false };
|
|
1062
|
+
}
|