@twinsearth/w2m-dsh-plugin 0.5.5 → 0.5.7
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 +92 -0
- package/CONTRIBUTING.md +123 -0
- package/README.md +3 -1
- package/SECURITY.md +162 -0
- package/TROUBLESHOOTING.md +787 -0
- package/lib/client.js +139 -14
- package/package.json +8 -5
- 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,1311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multicast DNS responder and single-shot discovery for the W2M LAN hint layer.
|
|
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. If you find yourself writing code where a discovered
|
|
9
|
+
* device is trusted because it was discovered, stop: that is the mistake, and it is why nothing in
|
|
10
|
+
* this file decides identity. This module reports what the LAN said; `src/plugin/mdns-discover.mjs`
|
|
11
|
+
* decides what may be *offered*, and even there the answer is "ask the relay".
|
|
12
|
+
*
|
|
13
|
+
* Concretely, and on purpose: mDNS has no authentication, no integrity and no confidentiality. Any
|
|
14
|
+
* host on the link can claim any name, and an off-path attacker can spray answers at a port it can
|
|
15
|
+
* guess. That is why the responder here never advertises a secret (a device token, a pairing code or a
|
|
16
|
+
* key of any kind must never go into a TXT record) and why the discovery source marks everything it
|
|
17
|
+
* finds `verified: false`.
|
|
18
|
+
*
|
|
19
|
+
* # Clean room
|
|
20
|
+
*
|
|
21
|
+
* Written from RFC 6762 (Multicast DNS), RFC 6763 (DNS-Based Service Discovery), RFC 2782 (SRV) and
|
|
22
|
+
* RFC 1035 §4.1 (message format). See `src/agent/mdns-wire.mjs` for the two studied-implementation
|
|
23
|
+
* behaviours this layer deliberately does *not* reproduce (first-writer-wins record updates, and a name
|
|
24
|
+
* treated as identity).
|
|
25
|
+
*
|
|
26
|
+
* # What this implements, exactly
|
|
27
|
+
*
|
|
28
|
+
* * `buildServiceRecords` — the four records a W2M service instance publishes: a shared PTR under
|
|
29
|
+
* `_w2m._tcp.local`, and unique SRV + TXT + A records. TTLs follow RFC 6762 §10: 120 s for records
|
|
30
|
+
* naming or containing a host name (SRV, A), 75 minutes for the others (PTR, TXT).
|
|
31
|
+
* * `createResponder` — announces on start, answers matching questions, re-announces on a timer, and
|
|
32
|
+
* sends a TTL=0 goodbye on close (RFC 6762 §10.1) so peers drop us immediately instead of waiting
|
|
33
|
+
* out a 75-minute TTL.
|
|
34
|
+
* * `discoverServices` — one bounded scan with a fresh socket per call, which is what RFC 6762 §5.1
|
|
35
|
+
* describes ("one-shot" queries from an ephemeral source port; §5.1 is also why the socket is
|
|
36
|
+
* never bound to 5353).
|
|
37
|
+
*
|
|
38
|
+
* # Scope, stated rather than glossed
|
|
39
|
+
*
|
|
40
|
+
* * **IPv4 only.** `224.0.0.251` and the `udp4` socket family. IPv6 (`ff02::fb`) is not half-built
|
|
41
|
+
* here; it would need its own socket, its own interface enumeration and its own tests.
|
|
42
|
+
* * **No probing or conflict resolution** (RFC 6762 §8.1, §9). A responder here announces its
|
|
43
|
+
* records immediately and does not defend them. Two machines configured with the same instance
|
|
44
|
+
* name would both answer. That is a real limitation, and it is acceptable *only* because of the
|
|
45
|
+
* rule above: a discovered name is a hint, and the pairing that actually authorises anything is
|
|
46
|
+
* keyed by machine id, not by the name two machines might collide on.
|
|
47
|
+
* * **No known-answer suppression** (RFC 6762 §7.1) and no negative responses (NSEC). A scan is one
|
|
48
|
+
* query; nothing here is a long-lived browsing client.
|
|
49
|
+
* * **The 10-second re-announce is a deliberate deviation.** RFC 6762 §8.3 says a responder "MUST NOT
|
|
50
|
+
* send regular periodic announcements as a matter of course" and asks for at least two
|
|
51
|
+
* announcements one second apart with the interval doubling. The periodic re-announce exists here
|
|
52
|
+
* because this is a small, fixed set of records on a tool that expects peers to appear and vanish,
|
|
53
|
+
* and because a peer that joined the link after our announcement would otherwise wait for its own
|
|
54
|
+
* query to find us. It is a knob (`announceIntervalMs`, `0` disables it), and the traffic is four
|
|
55
|
+
* records on a 10-second period.
|
|
56
|
+
* * **Name escapes are not parsed.** DNS-SD instance names may contain an escaped dot (`\.`); this
|
|
57
|
+
* codec would split it. Nothing in W2M's own names contains one, and identity does not come from
|
|
58
|
+
* the name, so the cost is a mis-split display name for a third-party service — not a wrong peer.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import dgram from 'node:dgram';
|
|
62
|
+
import os from 'node:os';
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
RECORD_TYPE,
|
|
66
|
+
decodeMessage,
|
|
67
|
+
encodeQuery,
|
|
68
|
+
encodeResponse,
|
|
69
|
+
isUsableUnicastIpv4,
|
|
70
|
+
nameEquals,
|
|
71
|
+
normalizeName,
|
|
72
|
+
} from './mdns-wire.mjs';
|
|
73
|
+
|
|
74
|
+
/** The IPv4 link-local multicast address every mDNS name resolves through (RFC 6762 §3). */
|
|
75
|
+
export const MDNS_GROUP = '224.0.0.251';
|
|
76
|
+
|
|
77
|
+
/** The mDNS UDP port (RFC 6762 §5). A source port of 5353 means "fully compliant querier". */
|
|
78
|
+
export const MDNS_PORT = 5353;
|
|
79
|
+
|
|
80
|
+
/** The service type W2M instances advertise under. */
|
|
81
|
+
export const DEFAULT_SERVICE_TYPE = '_w2m._tcp.local';
|
|
82
|
+
|
|
83
|
+
/** Default window for one scan. RFC 6762 §5.2 expects responses within about a second. */
|
|
84
|
+
export const DEFAULT_DISCOVERY_TIMEOUT_MS = 1500;
|
|
85
|
+
|
|
86
|
+
/** Default re-announce period. See the header: this is a deliberate deviation from RFC 6762 §8.3. */
|
|
87
|
+
export const DEFAULT_ANNOUNCE_INTERVAL_MS = 10_000;
|
|
88
|
+
|
|
89
|
+
/** RFC 6762 §10: 120 s where a host name is involved, 75 minutes (4500 s) for everything else. */
|
|
90
|
+
export const DEFAULT_TTL_SECONDS = Object.freeze({ ptr: 4500, srv: 120, txt: 4500, address: 120 });
|
|
91
|
+
|
|
92
|
+
/** RFC 6762 §10.1: a goodbye is the same record with an RR TTL of zero. */
|
|
93
|
+
export const GOODBYE_TTL = 0;
|
|
94
|
+
|
|
95
|
+
/** Upper bound on instances one scan will report, so a flooding peer cannot grow this without limit. */
|
|
96
|
+
export const MAX_SCAN_INSTANCES = 64;
|
|
97
|
+
|
|
98
|
+
/** The first TXT string DNS-SD asks for (RFC 6763 §6.1). */
|
|
99
|
+
export const TXT_VERSION = 'txtvers=1';
|
|
100
|
+
|
|
101
|
+
/** TXT keys this layer reads. `mid` is the *only* one that can carry identity, and it is the relay's. */
|
|
102
|
+
export const TXT_KEYS = Object.freeze({ machineId: 'mid', machineName: 'name', version: 'v' });
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------------------------
|
|
105
|
+
// Interfaces
|
|
106
|
+
// ---------------------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every IPv4 address this host has, internal ones included.
|
|
110
|
+
*
|
|
111
|
+
* The internal ones are *reported* rather than filtered out here, because "why is this machine not
|
|
112
|
+
* joining the group" is answered by the list, and the caller that decides is one call away. Joining
|
|
113
|
+
* `127.0.0.1` is what makes a loopback test possible, so the filter is a caller's policy, not a
|
|
114
|
+
* property of this function.
|
|
115
|
+
*
|
|
116
|
+
* @param {{includeInternal?: boolean}} [options]
|
|
117
|
+
* @returns {Array<{name: string, address: string, netmask: string|null, cidr: string|null, internal: boolean}>}
|
|
118
|
+
*/
|
|
119
|
+
export function listIPv4Interfaces(options = {}) {
|
|
120
|
+
const includeInternal = options.includeInternal !== false;
|
|
121
|
+
const out = [];
|
|
122
|
+
let interfaces;
|
|
123
|
+
try {
|
|
124
|
+
interfaces = os.networkInterfaces();
|
|
125
|
+
} catch {
|
|
126
|
+
// A platform that cannot enumerate interfaces is a machine with no LAN hint layer, not a crash.
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
130
|
+
for (const entry of entries ?? []) {
|
|
131
|
+
const family = typeof entry.family === 'string' ? entry.family : `IPv${entry.family}`;
|
|
132
|
+
const internal = entry.internal === true;
|
|
133
|
+
if (family !== 'IPv4') continue;
|
|
134
|
+
if (internal && !includeInternal) continue;
|
|
135
|
+
out.push({
|
|
136
|
+
name,
|
|
137
|
+
address: entry.address,
|
|
138
|
+
netmask: entry.netmask ?? null,
|
|
139
|
+
cidr: entry.cidr ?? null,
|
|
140
|
+
internal,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `name._w2m._tcp.local` from an instance label and a service type. */
|
|
148
|
+
export function serviceFqdn(instance, serviceType = DEFAULT_SERVICE_TYPE) {
|
|
149
|
+
const label = String(instance ?? '').trim();
|
|
150
|
+
if (label === '') throw new TypeError('serviceFqdn: an instance name is required');
|
|
151
|
+
return `${label}.${serviceType}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Split an instance FQDN back into `{instance, name}` (the first label, and the rest). */
|
|
155
|
+
export function splitInstanceFqdn(fqdn, serviceType = DEFAULT_SERVICE_TYPE) {
|
|
156
|
+
const text = String(fqdn ?? '');
|
|
157
|
+
const suffix = `.${serviceType}`;
|
|
158
|
+
const name = normalizeName(text).endsWith(normalizeName(suffix))
|
|
159
|
+
? text.slice(0, text.length - suffix.length)
|
|
160
|
+
: text;
|
|
161
|
+
return { instance: text, name };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `mac-studio` → `mac-studio.local`; an already-qualified `a.local` is left alone. */
|
|
165
|
+
export function toHostFqdn(host) {
|
|
166
|
+
const text = String(host ?? '')
|
|
167
|
+
.trim()
|
|
168
|
+
.replace(/\.$/, '');
|
|
169
|
+
if (text === '') throw new TypeError('toHostFqdn: a host name is required');
|
|
170
|
+
return normalizeName(text).endsWith('.local') ? text : `${text}.local`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A host name this host can call its own: the machine name, first label only, sanitised.
|
|
175
|
+
*
|
|
176
|
+
* `os.hostname()` may carry dots, spaces or non-ASCII on some platforms, none of which survive a DNS
|
|
177
|
+
* label intact. This is a *display* name in the mDNS sense; identity travels in the TXT `mid` key.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} [hostname]
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
export function localHostLabel(hostname = os.hostname()) {
|
|
183
|
+
const first = String(hostname).split('.')[0];
|
|
184
|
+
const cleaned = first
|
|
185
|
+
.replace(/[^A-Za-z0-9-]/g, '-')
|
|
186
|
+
.replace(/^-+|-+$/g, '')
|
|
187
|
+
.slice(0, 63);
|
|
188
|
+
return cleaned === '' ? 'w2m-host' : cleaned;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------------------------
|
|
192
|
+
// Records
|
|
193
|
+
// ---------------------------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The records one W2M service instance publishes.
|
|
197
|
+
*
|
|
198
|
+
* Layout follows DNS-SD (RFC 6763 §4–§6) with W2M's own keys in the TXT record:
|
|
199
|
+
*
|
|
200
|
+
* `_w2m._tcp.local` PTR → `<instance>._w2m._tcp.local` shared, TTL 4500
|
|
201
|
+
* `<instance>._w2m._tcp.local` SRV → `<host>.local` port unique, TTL 120
|
|
202
|
+
* `<instance>._w2m._tcp.local` TXT → txtvers=1, mid=…, name=… unique, TTL 4500
|
|
203
|
+
* `<host>.local` A → the advertised IPv4 address unique, TTL 120
|
|
204
|
+
*
|
|
205
|
+
* The TXT record carries the machine id the *relay* assigned (`mid=`). That is the only field in this
|
|
206
|
+
* whole layer that can be matched against the roster, and even then it is a claim: anyone on the link
|
|
207
|
+
* can put any `mid` in a TXT record. It buys the ability to *ask the relay about that machine*, which
|
|
208
|
+
* is exactly the amount of trust a hint deserves — see `src/plugin/mdns-discover.mjs`.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} options
|
|
211
|
+
* @param {string} options.instance Instance label.
|
|
212
|
+
* @param {string} [options.serviceType]
|
|
213
|
+
* @param {string} options.host Host name (a `.local` name, or a bare label).
|
|
214
|
+
* @param {number} options.servicePort The TCP port a peer would connect to.
|
|
215
|
+
* @param {string|null} [options.address] IPv4 address to publish, or null for no A record.
|
|
216
|
+
* @param {Record<string, string>|string[]} [options.txt] Extra TXT strings or key/value pairs.
|
|
217
|
+
* @param {string|null} [options.machineId] The relay's machine id, published as `mid=`.
|
|
218
|
+
* @param {string|null} [options.machineName] Display name, published as `name=`.
|
|
219
|
+
* @param {object} [options.ttl] Overrides for {@link DEFAULT_TTL_SECONDS}.
|
|
220
|
+
* @param {boolean} [options.goodbye] Emit the same records with TTL 0 (RFC 6762 §10.1).
|
|
221
|
+
* @returns {object[]}
|
|
222
|
+
*/
|
|
223
|
+
export function buildServiceRecords(options) {
|
|
224
|
+
const serviceType = options.serviceType ?? DEFAULT_SERVICE_TYPE;
|
|
225
|
+
const ttl = { ...DEFAULT_TTL_SECONDS, ...(options.ttl ?? {}) };
|
|
226
|
+
const goodbye = options.goodbye === true;
|
|
227
|
+
const at = (value) => (goodbye ? GOODBYE_TTL : value);
|
|
228
|
+
const instanceFqdn = serviceFqdn(options.instance, serviceType);
|
|
229
|
+
const hostFqdn = toHostFqdn(options.host ?? localHostLabel());
|
|
230
|
+
const port = Number(options.servicePort);
|
|
231
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
232
|
+
throw new TypeError(
|
|
233
|
+
`buildServiceRecords: servicePort must be an integer in 1..65535, got ${JSON.stringify(options.servicePort)}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const txt = [TXT_VERSION];
|
|
238
|
+
if (typeof options.machineId === 'string' && options.machineId !== '') {
|
|
239
|
+
txt.push(`${TXT_KEYS.machineId}=${options.machineId}`);
|
|
240
|
+
}
|
|
241
|
+
if (typeof options.machineName === 'string' && options.machineName !== '') {
|
|
242
|
+
txt.push(`${TXT_KEYS.machineName}=${options.machineName}`);
|
|
243
|
+
}
|
|
244
|
+
const extra = Array.isArray(options.txt)
|
|
245
|
+
? options.txt.map((entry) => String(entry)).filter(Boolean)
|
|
246
|
+
: Object.entries(options.txt ?? {}).map(([key, value]) => `${key}=${value}`);
|
|
247
|
+
for (const entry of extra) if (!txt.includes(entry)) txt.push(entry);
|
|
248
|
+
|
|
249
|
+
const records = [
|
|
250
|
+
// Shared: many responders may publish a PTR for the same service type, so no cache-flush bit.
|
|
251
|
+
{ name: serviceType, type: RECORD_TYPE.PTR, ttl: at(ttl.ptr), target: instanceFqdn },
|
|
252
|
+
// Unique: exactly one owner, so the cache-flush bit tells peers to drop older SRV/TXT/A records
|
|
253
|
+
// for these names (RFC 6762 §10.2). This is what stops a peer that moved from keeping the old
|
|
254
|
+
// address: the new record *replaces* rather than joins.
|
|
255
|
+
{
|
|
256
|
+
name: instanceFqdn,
|
|
257
|
+
type: RECORD_TYPE.SRV,
|
|
258
|
+
ttl: at(ttl.srv),
|
|
259
|
+
port,
|
|
260
|
+
priority: 0,
|
|
261
|
+
weight: 0,
|
|
262
|
+
target: hostFqdn,
|
|
263
|
+
cache_flush: !goodbye,
|
|
264
|
+
},
|
|
265
|
+
{ name: instanceFqdn, type: RECORD_TYPE.TXT, ttl: at(ttl.txt), txt, cache_flush: !goodbye },
|
|
266
|
+
];
|
|
267
|
+
if (typeof options.address === 'string' && options.address !== '') {
|
|
268
|
+
records.push({
|
|
269
|
+
name: hostFqdn,
|
|
270
|
+
type: RECORD_TYPE.A,
|
|
271
|
+
ttl: at(ttl.address),
|
|
272
|
+
address: options.address,
|
|
273
|
+
cache_flush: !goodbye,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// A goodbye is a *deletion*. RFC 6762 §10.1 has the TTL alone carry that meaning, and §10.2's
|
|
277
|
+
// cache-flush bit must not be set on it: a cooperating responder's own records for the same name
|
|
278
|
+
// must not be flushed along with ours.
|
|
279
|
+
return goodbye ? records.map((record) => ({ ...record, cache_flush: false })) : records;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------------------------
|
|
283
|
+
// The observation cache: last-writer-wins per record, TTL honoured
|
|
284
|
+
// ---------------------------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A short-lived record cache for one scan (or one long-lived querier).
|
|
288
|
+
*
|
|
289
|
+
* The update rule is the one thing this layer gets to choose, and it is chosen against the defect a
|
|
290
|
+
* studied implementation has: **there is no first-writer-wins and no "keep the first non-zero port".**
|
|
291
|
+
*
|
|
292
|
+
* * A record is identified by `(name, type, rdata)`. A later copy of the same record *replaces* the
|
|
293
|
+
* earlier one and restarts its TTL — the definition of a cache update (RFC 1035 §3.2.1, RFC 6762
|
|
294
|
+
* §10).
|
|
295
|
+
* * A record carrying the cache-flush bit replaces the whole RRset of that `(name, type)`: other
|
|
296
|
+
* RDATA for the same name and type is dropped. RFC 6762 §10.2 gives other members one second of
|
|
297
|
+
* grace for the multi-packet case; this cache has no second consumer and no peer reading it, so it
|
|
298
|
+
* drops them immediately, which is the *safe* direction — a stale address never survives.
|
|
299
|
+
* * A record with TTL 0 is a goodbye (RFC 6762 §10.1) and deletes every record with that
|
|
300
|
+
* `(name, type)` at once. RFC 6762 suggests a long-lived querier record TTL 1 and delete a second
|
|
301
|
+
* later, to give cooperating responders a chance to rescue the records; for a bounded scan the
|
|
302
|
+
* honest reading is "the peer said this is gone", and keeping it for a second would mean reporting
|
|
303
|
+
* a device that has already said goodbye.
|
|
304
|
+
* * Every entry carries `expires_at` from its own TTL, so a record whose TTL runs out mid-scan stops
|
|
305
|
+
* being reported the moment it expires.
|
|
306
|
+
*
|
|
307
|
+
* @param {{now?: () => number, max?: number}} [options]
|
|
308
|
+
* @returns {object}
|
|
309
|
+
*/
|
|
310
|
+
export function createRecordCache(options = {}) {
|
|
311
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
312
|
+
const max = Number.isInteger(options.max) && options.max > 0 ? options.max : 512;
|
|
313
|
+
/** @type {Map<string, {record: object, at: number, observed_address: string|null, expires_at: number}>} */
|
|
314
|
+
const entries = new Map();
|
|
315
|
+
|
|
316
|
+
const nameTypeKey = (record) => `${normalizeName(record.name)}|${record.type}`;
|
|
317
|
+
const recordKey = (record) => `${nameTypeKey(record)}|${record.rdata.toString('hex')}`;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Apply one decoded record.
|
|
321
|
+
*
|
|
322
|
+
* @param {object} record A record from {@link decodeMessage}.
|
|
323
|
+
* @param {{at?: number, observed_address?: string|null}} [context]
|
|
324
|
+
* @returns {{action: 'set'|'goodbye', key: string, flushed: number, entries: number}}
|
|
325
|
+
*/
|
|
326
|
+
function apply(record, context = {}) {
|
|
327
|
+
const at = Number.isFinite(context.at) ? context.at : now();
|
|
328
|
+
const observed = typeof context.observed_address === 'string' ? context.observed_address : null;
|
|
329
|
+
const setKey = nameTypeKey(record);
|
|
330
|
+
let flushed = 0;
|
|
331
|
+
|
|
332
|
+
for (const [key, entry] of entries) {
|
|
333
|
+
if (!key.startsWith(`${setKey}|`)) continue;
|
|
334
|
+
if (record.ttl === 0 || record.cache_flush === true) {
|
|
335
|
+
entries.delete(key);
|
|
336
|
+
flushed += 1;
|
|
337
|
+
} else if (entry.expires_at <= at) {
|
|
338
|
+
// Expired on arrival: dropping it keeps "the cache is what is still true" a property of this
|
|
339
|
+
// structure rather than of everyone who reads it.
|
|
340
|
+
entries.delete(key);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (record.ttl === 0) return { action: 'goodbye', key: setKey, flushed, entries: entries.size };
|
|
345
|
+
|
|
346
|
+
entries.set(recordKey(record), {
|
|
347
|
+
record,
|
|
348
|
+
at,
|
|
349
|
+
observed_address: observed,
|
|
350
|
+
expires_at: at + record.ttl * 1000,
|
|
351
|
+
});
|
|
352
|
+
while (entries.size > max) {
|
|
353
|
+
const oldest = entries.keys().next();
|
|
354
|
+
if (oldest.done === true) break;
|
|
355
|
+
entries.delete(oldest.value);
|
|
356
|
+
}
|
|
357
|
+
return { action: 'set', key: recordKey(record), flushed, entries: entries.size };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Every record still true, oldest observation first.
|
|
362
|
+
*
|
|
363
|
+
* @param {{at?: number}} [context]
|
|
364
|
+
* @returns {Array<{record: object, at: number, observed_address: string|null, expires_at: number}>}
|
|
365
|
+
*/
|
|
366
|
+
function live(context = {}) {
|
|
367
|
+
const at = Number.isFinite(context.at) ? context.at : now();
|
|
368
|
+
const out = [];
|
|
369
|
+
for (const [key, entry] of entries) {
|
|
370
|
+
if (entry.expires_at <= at) {
|
|
371
|
+
entries.delete(key);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
out.push(entry);
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
apply,
|
|
381
|
+
live,
|
|
382
|
+
get size() {
|
|
383
|
+
return entries.size;
|
|
384
|
+
},
|
|
385
|
+
clear: () => entries.clear(),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------------------------
|
|
390
|
+
// Reassembly: records → service instances
|
|
391
|
+
// ---------------------------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Assemble decoded records into service instances.
|
|
395
|
+
*
|
|
396
|
+
* An mDNS answer for one service arrives as several records — often across several packets — and the
|
|
397
|
+
* only thing that ties them together is the *name*: the PTR names the instance, the SRV and TXT are
|
|
398
|
+
* owned by that instance name, and the A record is owned by the SRV target. Reassembly therefore
|
|
399
|
+
* follows names, never packet boundaries, and picks the **latest** record of each `(name, type)` when
|
|
400
|
+
* several are present. That is the same rule as {@link createRecordCache}'s, applied a second time so
|
|
401
|
+
* that a caller handing this function a flat list of records (as a test does) gets the same answer as
|
|
402
|
+
* a caller handing it a cache snapshot.
|
|
403
|
+
*
|
|
404
|
+
* @param {Array<object|{record: object, at?: number, observed_address?: string|null}>} input
|
|
405
|
+
* @param {{serviceType?: string, max?: number, now?: () => number}} [options]
|
|
406
|
+
* @returns {{instances: object[], truncated: number, counts: object}}
|
|
407
|
+
*/
|
|
408
|
+
export function parseServiceInstances(input, options = {}) {
|
|
409
|
+
const serviceType = options.serviceType ?? DEFAULT_SERVICE_TYPE;
|
|
410
|
+
const max = Number.isInteger(options.max) && options.max > 0 ? options.max : MAX_SCAN_INSTANCES;
|
|
411
|
+
const entries = (Array.isArray(input) ? input : [])
|
|
412
|
+
.map((entry, index) =>
|
|
413
|
+
entry && typeof entry === 'object' && entry.record
|
|
414
|
+
? { ...entry, index }
|
|
415
|
+
: { record: entry, at: 0, observed_address: null, index },
|
|
416
|
+
)
|
|
417
|
+
.filter((entry) => entry.record && typeof entry.record === 'object');
|
|
418
|
+
const live = entries.filter((entry) => entry.record.ttl !== 0);
|
|
419
|
+
|
|
420
|
+
/** Keep the latest entry per key: last writer wins, ties broken by arrival order. */
|
|
421
|
+
const latestBy = (filter, keyOf) => {
|
|
422
|
+
const kept = new Map();
|
|
423
|
+
for (const entry of live) {
|
|
424
|
+
if (!filter(entry.record)) continue;
|
|
425
|
+
const key = keyOf(entry.record);
|
|
426
|
+
if (key === null) continue;
|
|
427
|
+
const previous = kept.get(key);
|
|
428
|
+
if (
|
|
429
|
+
previous === undefined ||
|
|
430
|
+
entry.at > previous.at ||
|
|
431
|
+
(entry.at === previous.at && entry.index >= previous.index)
|
|
432
|
+
) {
|
|
433
|
+
kept.set(key, entry);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return [...kept.values()];
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const ptrs = latestBy(
|
|
440
|
+
(record) =>
|
|
441
|
+
record.type === RECORD_TYPE.PTR &&
|
|
442
|
+
nameEquals(record.name, serviceType) &&
|
|
443
|
+
typeof record.target === 'string',
|
|
444
|
+
(record) => normalizeName(record.target),
|
|
445
|
+
);
|
|
446
|
+
const srvs = latestBy(
|
|
447
|
+
(record) => record.type === RECORD_TYPE.SRV,
|
|
448
|
+
(record) => normalizeName(record.name),
|
|
449
|
+
);
|
|
450
|
+
const txts = latestBy(
|
|
451
|
+
(record) => record.type === RECORD_TYPE.TXT,
|
|
452
|
+
(record) => normalizeName(record.name),
|
|
453
|
+
);
|
|
454
|
+
const addresses = new Map();
|
|
455
|
+
for (const entry of live) {
|
|
456
|
+
const record = entry.record;
|
|
457
|
+
if (record.type !== RECORD_TYPE.A && record.type !== RECORD_TYPE.AAAA) continue;
|
|
458
|
+
const key = normalizeName(record.name);
|
|
459
|
+
if (!addresses.has(key)) addresses.set(key, new Map());
|
|
460
|
+
addresses.get(key).set(record.address, entry);
|
|
461
|
+
}
|
|
462
|
+
// Count SRV records that lost the latest-writer comparison, so a caller can see that a device moved
|
|
463
|
+
// during the scan rather than having to infer it.
|
|
464
|
+
const srvCounts = new Map();
|
|
465
|
+
for (const entry of live) {
|
|
466
|
+
if (entry.record.type !== RECORD_TYPE.SRV) continue;
|
|
467
|
+
const key = normalizeName(entry.record.name);
|
|
468
|
+
srvCounts.set(key, (srvCounts.get(key) ?? 0) + 1);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const srvsByName = new Map(srvs.map((entry) => [normalizeName(entry.record.name), entry]));
|
|
472
|
+
const txtsByName = new Map(txts.map((entry) => [normalizeName(entry.record.name), entry]));
|
|
473
|
+
// A PTR names its instance in its *target*; an SRV or TXT names it in its *owner*. An instance seen
|
|
474
|
+
// without a PTR (a peer that answered with only SRV/TXT, or a scan that started mid-answer) is still
|
|
475
|
+
// the same instance, so it is assembled the same way and merely flagged `advertised_via_ptr: false`.
|
|
476
|
+
const ordered = ptrs.map((entry) => ({ entry, instanceFqdn: entry.record.target, viaPtr: true }));
|
|
477
|
+
for (const entry of srvs) {
|
|
478
|
+
if (ptrs.some((ptr) => nameEquals(ptr.record.target, entry.record.name))) continue;
|
|
479
|
+
ordered.push({ entry, instanceFqdn: entry.record.name, viaPtr: false });
|
|
480
|
+
}
|
|
481
|
+
ordered.sort((a, b) =>
|
|
482
|
+
a.entry.at === b.entry.at ? a.entry.index - b.entry.index : a.entry.at - b.entry.at,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const instances = [];
|
|
486
|
+
for (const item of ordered) {
|
|
487
|
+
const { entry: ptr, instanceFqdn, viaPtr } = item;
|
|
488
|
+
const key = normalizeName(instanceFqdn);
|
|
489
|
+
const srv = srvsByName.get(key) ?? null;
|
|
490
|
+
const txt = txtsByName.get(key) ?? null;
|
|
491
|
+
const host = srv?.record.target ?? null;
|
|
492
|
+
const addressEntries = host === null ? [] : [...(addresses.get(normalizeName(host))?.values() ?? [])];
|
|
493
|
+
const txtStrings = Array.isArray(txt?.record.txt) ? txt.record.txt : [];
|
|
494
|
+
instances.push({
|
|
495
|
+
instance: instanceFqdn,
|
|
496
|
+
name: splitInstanceFqdn(instanceFqdn, serviceType).name,
|
|
497
|
+
service_type: serviceType,
|
|
498
|
+
host,
|
|
499
|
+
port: srv?.record.port ?? null,
|
|
500
|
+
priority: srv?.record.priority ?? null,
|
|
501
|
+
weight: srv?.record.weight ?? null,
|
|
502
|
+
addresses: addressEntries.map((entry) => entry.record.address),
|
|
503
|
+
// Where the packet that named this instance actually came from. A hint about the address that
|
|
504
|
+
// is independent of the A record, and often the more useful one on a link where peers do not
|
|
505
|
+
// publish address records at all.
|
|
506
|
+
observed_address: srv?.observed_address ?? ptr.observed_address ?? null,
|
|
507
|
+
txt: { raw: txtStrings, map: txtMap(txtStrings) },
|
|
508
|
+
ttl: {
|
|
509
|
+
ptr: ptr.record.ttl,
|
|
510
|
+
srv: srv?.record.ttl ?? null,
|
|
511
|
+
txt: txt?.record.ttl ?? null,
|
|
512
|
+
},
|
|
513
|
+
advertised_via_ptr: viaPtr,
|
|
514
|
+
saw_srv: srv !== null,
|
|
515
|
+
saw_txt: txt !== null,
|
|
516
|
+
// How many SRV records for this instance arrived before the winning one. A device that moved
|
|
517
|
+
// during the scan leaves a trail here; it does not leave a stale port in `port`.
|
|
518
|
+
superseded_srv: Math.max(0, (srvCounts.get(key) ?? 1) - 1),
|
|
519
|
+
// The newest record of any kind that belongs to this instance, address records included: an
|
|
520
|
+
// instance whose address is the only thing that changed is still an instance that changed.
|
|
521
|
+
updated_at: Math.max(ptr.at, srv?.at ?? 0, txt?.at ?? 0, ...addressEntries.map((entry) => entry.at)),
|
|
522
|
+
});
|
|
523
|
+
if (instances.length >= max) break;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
instances,
|
|
528
|
+
truncated: Math.max(0, ordered.length - instances.length),
|
|
529
|
+
counts: {
|
|
530
|
+
records: entries.length,
|
|
531
|
+
ptr: ptrs.length,
|
|
532
|
+
srv: srvs.length,
|
|
533
|
+
txt: txts.length,
|
|
534
|
+
addresses: [...addresses.values()].reduce((total, set) => total + set.size, 0),
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* TXT character-strings → a key/value map.
|
|
541
|
+
*
|
|
542
|
+
* RFC 6763 §6.1: a string with no `=` is a key with an empty value; a duplicate key keeps the first
|
|
543
|
+
* occurrence (the RFC's rule for its own keys).
|
|
544
|
+
*
|
|
545
|
+
* @param {string[]} strings
|
|
546
|
+
* @returns {Record<string, string>}
|
|
547
|
+
*/
|
|
548
|
+
export function txtMap(strings) {
|
|
549
|
+
const map = {};
|
|
550
|
+
for (const text of Array.isArray(strings) ? strings : []) {
|
|
551
|
+
const index = text.indexOf('=');
|
|
552
|
+
const key = index === -1 ? text : text.slice(0, index);
|
|
553
|
+
const value = index === -1 ? '' : text.slice(index + 1);
|
|
554
|
+
if (key === '' || Object.prototype.hasOwnProperty.call(map, key)) continue;
|
|
555
|
+
map[key] = value;
|
|
556
|
+
}
|
|
557
|
+
return map;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------------------------
|
|
561
|
+
// Socket plumbing
|
|
562
|
+
// ---------------------------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Bind a `udp4` socket, resolving with it or rejecting with the platform's error.
|
|
566
|
+
*
|
|
567
|
+
* `reuseAddr` is set because on a machine where another mDNS responder (Bonjour, systemd-resolved,
|
|
568
|
+
* avahi) already holds 5353 the bind must still succeed: sharing the port is the normal case for mDNS,
|
|
569
|
+
* and refusing to start because someone else is also listening would make this layer useless on
|
|
570
|
+
* exactly the machines that need it most.
|
|
571
|
+
*
|
|
572
|
+
* @param {{address?: string, port?: number, reuseAddr?: boolean}} [options]
|
|
573
|
+
* @returns {Promise<import('node:dgram').Socket>}
|
|
574
|
+
*/
|
|
575
|
+
export function bindUdpSocket(options = {}) {
|
|
576
|
+
const address = options.address ?? '0.0.0.0';
|
|
577
|
+
const port = Number.isInteger(options.port) ? options.port : 0;
|
|
578
|
+
return new Promise((resolve, reject) => {
|
|
579
|
+
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: options.reuseAddr !== false });
|
|
580
|
+
const onError = (error) => {
|
|
581
|
+
socket.removeListener('listening', onListening);
|
|
582
|
+
try {
|
|
583
|
+
socket.close();
|
|
584
|
+
} catch {
|
|
585
|
+
/* already closed */
|
|
586
|
+
}
|
|
587
|
+
reject(error);
|
|
588
|
+
};
|
|
589
|
+
const onListening = () => {
|
|
590
|
+
socket.removeListener('error', onError);
|
|
591
|
+
resolve(socket);
|
|
592
|
+
};
|
|
593
|
+
socket.once('error', onError);
|
|
594
|
+
socket.once('listening', onListening);
|
|
595
|
+
socket.bind(port, address);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Send one datagram, resolving with a report instead of rejecting.
|
|
601
|
+
*
|
|
602
|
+
* Every send in this module goes through here for one reason: a locked-down network, a container with
|
|
603
|
+
* no multicast route, or an interface that vanished mid-scan must produce a *reported* failure, never
|
|
604
|
+
* an unhandled rejection and never a silent success.
|
|
605
|
+
*
|
|
606
|
+
* @param {import('node:dgram').Socket} socket
|
|
607
|
+
* @param {Buffer} bytes
|
|
608
|
+
* @param {{address: string, port: number}} target
|
|
609
|
+
* @returns {Promise<{ok: boolean, target: object, bytes: number, error: string|null, code: string|null}>}
|
|
610
|
+
*/
|
|
611
|
+
export function sendDatagram(socket, bytes, target) {
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
if (!socket) {
|
|
614
|
+
resolve({ ok: false, target, bytes: bytes.length, error: 'no socket', code: 'MDNS_NO_SOCKET' });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
socket.send(bytes, target.port, target.address, (error) => {
|
|
619
|
+
resolve({
|
|
620
|
+
ok: !error,
|
|
621
|
+
target,
|
|
622
|
+
bytes: bytes.length,
|
|
623
|
+
error: error ? `${error.code ?? 'ERROR'}: ${error.message}` : null,
|
|
624
|
+
code: error ? (error.code ?? 'MDNS_SEND_FAILED') : null,
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
} catch (error) {
|
|
628
|
+
resolve({
|
|
629
|
+
ok: false,
|
|
630
|
+
target,
|
|
631
|
+
bytes: bytes.length,
|
|
632
|
+
error: `${error?.code ?? 'ERROR'}: ${error?.message ?? String(error)}`,
|
|
633
|
+
code: error?.code ?? 'MDNS_SEND_FAILED',
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Close a socket without ever throwing: closing twice is a no-op. */
|
|
640
|
+
export function closeSocket(socket) {
|
|
641
|
+
return new Promise((resolve) => {
|
|
642
|
+
if (!socket) {
|
|
643
|
+
resolve();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
socket.close(() => resolve());
|
|
648
|
+
} catch {
|
|
649
|
+
resolve();
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ---------------------------------------------------------------------------------------------
|
|
655
|
+
// Responder
|
|
656
|
+
// ---------------------------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* An mDNS responder for one W2M service instance.
|
|
660
|
+
*
|
|
661
|
+
* @param {object} options
|
|
662
|
+
* @param {string} options.servicePort The TCP port a peer would connect to. Required.
|
|
663
|
+
* @param {string} [options.instance] Instance label; defaults to this host's name.
|
|
664
|
+
* @param {string} [options.host] Host label for the SRV target and the A record.
|
|
665
|
+
* @param {string} [options.serviceType]
|
|
666
|
+
* @param {string|null} [options.address] IPv4 address to advertise. Defaults to the first usable
|
|
667
|
+
* non-internal address at `start()` time.
|
|
668
|
+
* @param {string|null} [options.machineId] Published as `mid=` in TXT. Never a secret.
|
|
669
|
+
* @param {string|null} [options.machineName] Published as `name=` in TXT.
|
|
670
|
+
* @param {Record<string, string>} [options.txt]
|
|
671
|
+
* @param {string} [options.group]
|
|
672
|
+
* @param {number} [options.mdnsPort] The port queried and the port a "legacy" reply is recognised by.
|
|
673
|
+
* @param {string} [options.bindAddress]
|
|
674
|
+
* @param {number} [options.announceIntervalMs] `0` disables the re-announce timer.
|
|
675
|
+
* @param {boolean} [options.multicastLoopback] Passed to `setMulticastLoopback`, in a try/catch.
|
|
676
|
+
* @param {number} [options.multicastTtl] Passed to `setMulticastTTL`, in a try/catch.
|
|
677
|
+
* @param {() => Array<object>} [options.interfaces] Injectable interface list, for tests.
|
|
678
|
+
* @param {(bytes: Buffer, target: {address: string, port: number}) => void} [options.sink] Where
|
|
679
|
+
* datagrams go. Defaults to the socket; a test passes one to exercise the packet path with no socket.
|
|
680
|
+
* @param {(line: string) => void} [options.logger] One-line diagnostics. Never fatal.
|
|
681
|
+
* @param {() => number} [options.now]
|
|
682
|
+
* @returns {object}
|
|
683
|
+
*/
|
|
684
|
+
export function createResponder(options = {}) {
|
|
685
|
+
const group = options.group ?? MDNS_GROUP;
|
|
686
|
+
const mdnsPort = Number.isInteger(options.mdnsPort) ? options.mdnsPort : MDNS_PORT;
|
|
687
|
+
const serviceType = options.serviceType ?? DEFAULT_SERVICE_TYPE;
|
|
688
|
+
const instance = String(options.instance ?? localHostLabel());
|
|
689
|
+
const host = toHostFqdn(options.host ?? localHostLabel());
|
|
690
|
+
const bindAddress = options.bindAddress ?? '0.0.0.0';
|
|
691
|
+
const announceIntervalMs = Number.isInteger(options.announceIntervalMs)
|
|
692
|
+
? options.announceIntervalMs
|
|
693
|
+
: DEFAULT_ANNOUNCE_INTERVAL_MS;
|
|
694
|
+
const listInterfaces = typeof options.interfaces === 'function' ? options.interfaces : listIPv4Interfaces;
|
|
695
|
+
const sink = typeof options.sink === 'function' ? options.sink : null;
|
|
696
|
+
const log = typeof options.logger === 'function' ? options.logger : null;
|
|
697
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
698
|
+
const instanceFqdn = serviceFqdn(instance, serviceType);
|
|
699
|
+
|
|
700
|
+
/** @type {import('node:dgram').Socket|null} */
|
|
701
|
+
let socket = null;
|
|
702
|
+
let timer = null;
|
|
703
|
+
let startedAt = null;
|
|
704
|
+
let closed = false;
|
|
705
|
+
let goodbyeSent = false;
|
|
706
|
+
let announcedCount = 0;
|
|
707
|
+
let lastAnnounceAt = null;
|
|
708
|
+
let replies = 0;
|
|
709
|
+
let lastError = null;
|
|
710
|
+
let lastRefusal = null;
|
|
711
|
+
let refusedPackets = 0;
|
|
712
|
+
let ignoredResponses = 0;
|
|
713
|
+
let ignoredOpcode = 0;
|
|
714
|
+
let unanswered = 0;
|
|
715
|
+
let advertisedAddress =
|
|
716
|
+
typeof options.address === 'string' && options.address !== '' ? options.address : null;
|
|
717
|
+
const memberships = [];
|
|
718
|
+
const knobs = { multicast_loopback: null, multicast_ttl: null };
|
|
719
|
+
const errors = [];
|
|
720
|
+
|
|
721
|
+
const note = (line) => {
|
|
722
|
+
lastError = line;
|
|
723
|
+
errors.push({ at: now(), line });
|
|
724
|
+
if (errors.length > 20) errors.shift();
|
|
725
|
+
try {
|
|
726
|
+
log?.(line);
|
|
727
|
+
} catch {
|
|
728
|
+
/* a logger that throws must not take the responder with it */
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
/** The records this responder owns right now. */
|
|
733
|
+
function records() {
|
|
734
|
+
return buildServiceRecords({
|
|
735
|
+
instance,
|
|
736
|
+
serviceType,
|
|
737
|
+
host,
|
|
738
|
+
servicePort: options.servicePort,
|
|
739
|
+
address: advertisedAddress,
|
|
740
|
+
machineId: options.machineId ?? null,
|
|
741
|
+
machineName: options.machineName ?? null,
|
|
742
|
+
txt: options.txt ?? {},
|
|
743
|
+
ttl: options.ttl,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Where a datagram goes: the injected sink when there is one, the socket otherwise. */
|
|
748
|
+
async function send(bytes, target) {
|
|
749
|
+
if (sink) {
|
|
750
|
+
try {
|
|
751
|
+
sink(bytes, target);
|
|
752
|
+
return { ok: true, target, bytes: bytes.length, error: null, code: null };
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return {
|
|
755
|
+
ok: false,
|
|
756
|
+
target,
|
|
757
|
+
bytes: bytes.length,
|
|
758
|
+
error: String(error?.message ?? error),
|
|
759
|
+
code: 'MDNS_SINK_FAILED',
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return sendDatagram(socket, bytes, target);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Multicast our records (RFC 6762 §8.3), or the TTL=0 goodbye when `goodbye` is set (§10.1).
|
|
768
|
+
*
|
|
769
|
+
* @param {{goodbye?: boolean}} [announceOptions]
|
|
770
|
+
* @returns {Promise<object>}
|
|
771
|
+
*/
|
|
772
|
+
async function announce(announceOptions = {}) {
|
|
773
|
+
const goodbye = announceOptions.goodbye === true;
|
|
774
|
+
let bytes;
|
|
775
|
+
try {
|
|
776
|
+
bytes = encodeResponse({
|
|
777
|
+
query: null,
|
|
778
|
+
answers: buildServiceRecords({
|
|
779
|
+
instance,
|
|
780
|
+
serviceType,
|
|
781
|
+
host,
|
|
782
|
+
servicePort: options.servicePort,
|
|
783
|
+
address: advertisedAddress,
|
|
784
|
+
machineId: options.machineId ?? null,
|
|
785
|
+
machineName: options.machineName ?? null,
|
|
786
|
+
txt: options.txt ?? {},
|
|
787
|
+
ttl: options.ttl,
|
|
788
|
+
goodbye,
|
|
789
|
+
}),
|
|
790
|
+
});
|
|
791
|
+
} catch (error) {
|
|
792
|
+
note(`mDNS: could not build the ${goodbye ? 'goodbye' : 'announcement'}: ${error?.message ?? error}`);
|
|
793
|
+
return { ok: false, bytes: 0, error: String(error?.message ?? error) };
|
|
794
|
+
}
|
|
795
|
+
const result = await send(bytes, { address: group, port: mdnsPort });
|
|
796
|
+
if (!result.ok)
|
|
797
|
+
note(`mDNS: ${goodbye ? 'goodbye' : 'announcement'} to ${group}:${mdnsPort} failed (${result.error})`);
|
|
798
|
+
if (goodbye) goodbyeSent = result.ok;
|
|
799
|
+
else {
|
|
800
|
+
announcedCount += 1;
|
|
801
|
+
lastAnnounceAt = now();
|
|
802
|
+
}
|
|
803
|
+
return result;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Match a query's questions against our records.
|
|
808
|
+
*
|
|
809
|
+
* The whole rule, stated once: answer a question about *our* service type, *our* instance, or *our*
|
|
810
|
+
* host, for a type we have; carry the rest of the set in the additional section so a peer does not
|
|
811
|
+
* have to ask a second time.
|
|
812
|
+
*
|
|
813
|
+
* @param {object[]} questions
|
|
814
|
+
* @returns {{answers: object[], additionals: object[], matched: number, unicast_requested: boolean}}
|
|
815
|
+
*/
|
|
816
|
+
function matchQuestions(questions) {
|
|
817
|
+
const owned = records();
|
|
818
|
+
const byKey = new Map(owned.map((record) => [`${normalizeName(record.name)}|${record.type}`, record]));
|
|
819
|
+
const answers = [];
|
|
820
|
+
const additionals = [];
|
|
821
|
+
let matched = 0;
|
|
822
|
+
let unicastRequested = false;
|
|
823
|
+
|
|
824
|
+
const want = (name, type) => {
|
|
825
|
+
const wanted = type === RECORD_TYPE.ANY ? null : type;
|
|
826
|
+
const found = [];
|
|
827
|
+
for (const [key, record] of byKey) {
|
|
828
|
+
if (!key.startsWith(`${normalizeName(name)}|`)) continue;
|
|
829
|
+
if (wanted !== null && record.type !== wanted) continue;
|
|
830
|
+
found.push(record);
|
|
831
|
+
}
|
|
832
|
+
return found;
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
for (const question of questions ?? []) {
|
|
836
|
+
if (question.unicast_response === true) unicastRequested = true;
|
|
837
|
+
const aboutService = nameEquals(question.name, serviceType);
|
|
838
|
+
const aboutInstance = nameEquals(question.name, instanceFqdn);
|
|
839
|
+
const aboutHost = nameEquals(question.name, host);
|
|
840
|
+
if (!aboutService && !aboutInstance && !aboutHost) continue;
|
|
841
|
+
|
|
842
|
+
const direct = want(question.name, question.type);
|
|
843
|
+
if (direct.length === 0) continue;
|
|
844
|
+
matched += 1;
|
|
845
|
+
for (const record of direct) if (!answers.includes(record)) answers.push(record);
|
|
846
|
+
|
|
847
|
+
// The "additional" records a peer needs next: PTR → SRV + TXT, SRV → A.
|
|
848
|
+
const extras = aboutService
|
|
849
|
+
? [
|
|
850
|
+
...want(instanceFqdn, RECORD_TYPE.SRV),
|
|
851
|
+
...want(instanceFqdn, RECORD_TYPE.TXT),
|
|
852
|
+
...want(host, RECORD_TYPE.A),
|
|
853
|
+
]
|
|
854
|
+
: aboutInstance
|
|
855
|
+
? [...want(host, RECORD_TYPE.A)]
|
|
856
|
+
: [];
|
|
857
|
+
for (const record of extras) {
|
|
858
|
+
if (answers.includes(record) || additionals.includes(record)) continue;
|
|
859
|
+
additionals.push(record);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return { answers, additionals, matched, unicast_requested: unicastRequested };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Handle one received datagram: decode, filter, match, reply.
|
|
867
|
+
*
|
|
868
|
+
* Never throws: a malformed packet is counted and a query we do not answer is counted, and both are
|
|
869
|
+
* values on this object rather than exceptions in a socket callback.
|
|
870
|
+
*
|
|
871
|
+
* @param {Buffer} bytes
|
|
872
|
+
* @param {{address?: string, port?: number, family?: string}} rinfo
|
|
873
|
+
* @returns {object}
|
|
874
|
+
*/
|
|
875
|
+
function handleMessage(bytes, rinfo = {}) {
|
|
876
|
+
const outcome = {
|
|
877
|
+
handled: false,
|
|
878
|
+
reason: null,
|
|
879
|
+
responded: false,
|
|
880
|
+
legacy: false,
|
|
881
|
+
unicast: false,
|
|
882
|
+
answers: 0,
|
|
883
|
+
additionals: 0,
|
|
884
|
+
bytes: 0,
|
|
885
|
+
};
|
|
886
|
+
let decoded;
|
|
887
|
+
try {
|
|
888
|
+
decoded = decodeMessage(bytes);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
// decodeMessage refuses rather than throwing, but a responder must not be the one place that
|
|
891
|
+
// trusts that claim enough to skip the catch.
|
|
892
|
+
refusedPackets += 1;
|
|
893
|
+
lastRefusal = 'MDNS_INTERNAL';
|
|
894
|
+
outcome.reason = `internal: ${error?.message ?? error}`;
|
|
895
|
+
return outcome;
|
|
896
|
+
}
|
|
897
|
+
if (!decoded.ok) {
|
|
898
|
+
refusedPackets += 1;
|
|
899
|
+
lastRefusal = decoded.reason;
|
|
900
|
+
outcome.reason = decoded.reason;
|
|
901
|
+
return outcome;
|
|
902
|
+
}
|
|
903
|
+
const message = decoded.message;
|
|
904
|
+
outcome.handled = true;
|
|
905
|
+
|
|
906
|
+
// RFC 6762 §18.3: an opcode other than zero is not a standard query and must be ignored.
|
|
907
|
+
if (message.flags.opcode !== 0) {
|
|
908
|
+
ignoredOpcode += 1;
|
|
909
|
+
outcome.reason = 'opcode';
|
|
910
|
+
return outcome;
|
|
911
|
+
}
|
|
912
|
+
// A response. We implement no conflict detection, so nothing here reacts to a peer's answer; it is
|
|
913
|
+
// counted, because "someone on this link is announcing something" is worth being able to see.
|
|
914
|
+
if (message.flags.qr) {
|
|
915
|
+
ignoredResponses += 1;
|
|
916
|
+
outcome.reason = 'response';
|
|
917
|
+
return outcome;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const match = matchQuestions(message.questions);
|
|
921
|
+
if (match.matched === 0) {
|
|
922
|
+
unanswered += 1;
|
|
923
|
+
outcome.reason = 'no_matching_question';
|
|
924
|
+
return outcome;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// RFC 6762 §6.7: a query whose source port is not 5353 comes from a one-shot resolver, and MUST be
|
|
928
|
+
// answered directly, with the query id and question repeated and short TTLs. §5.4: a question with
|
|
929
|
+
// the unicast-response bit asks for the same treatment.
|
|
930
|
+
const legacy = rinfo.port !== mdnsPort;
|
|
931
|
+
const unicast = legacy || match.unicast_requested;
|
|
932
|
+
let reply;
|
|
933
|
+
try {
|
|
934
|
+
reply = encodeResponse({
|
|
935
|
+
query: message,
|
|
936
|
+
answers: match.answers,
|
|
937
|
+
additionals: match.additionals,
|
|
938
|
+
legacy,
|
|
939
|
+
});
|
|
940
|
+
} catch (error) {
|
|
941
|
+
note(`mDNS: could not build a reply: ${error?.message ?? error}`);
|
|
942
|
+
outcome.reason = 'reply_failed';
|
|
943
|
+
return outcome;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const target = unicast
|
|
947
|
+
? { address: rinfo.address ?? group, port: Number.isInteger(rinfo.port) ? rinfo.port : mdnsPort }
|
|
948
|
+
: { address: group, port: mdnsPort };
|
|
949
|
+
outcome.legacy = legacy;
|
|
950
|
+
outcome.unicast = unicast;
|
|
951
|
+
outcome.answers = match.answers.length;
|
|
952
|
+
outcome.additionals = match.additionals.length;
|
|
953
|
+
outcome.bytes = reply.length;
|
|
954
|
+
outcome.target = target;
|
|
955
|
+
// Fire and forget on purpose: a responder must not queue behind a slow send, and a failed send is
|
|
956
|
+
// reported through `last_error` rather than thrown into an event handler.
|
|
957
|
+
send(reply, target)
|
|
958
|
+
.then((result) => {
|
|
959
|
+
if (result.ok) replies += 1;
|
|
960
|
+
else note(`mDNS: reply to ${target.address}:${target.port} failed (${result.error})`);
|
|
961
|
+
})
|
|
962
|
+
.catch(() => {});
|
|
963
|
+
outcome.responded = true;
|
|
964
|
+
return outcome;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Bind, join, announce, and start re-announcing.
|
|
969
|
+
*
|
|
970
|
+
* @returns {Promise<object>} {@link createResponder}'s `status()`.
|
|
971
|
+
*/
|
|
972
|
+
async function start() {
|
|
973
|
+
if (socket !== null && !closed) return status();
|
|
974
|
+
if (startedAt === null) startedAt = now();
|
|
975
|
+
try {
|
|
976
|
+
socket = await bindUdpSocket({ address: bindAddress, port: mdnsPort, reuseAddr: true });
|
|
977
|
+
} catch (error) {
|
|
978
|
+
// Bind failure is reported, not thrown: on a container with no multicast the caller logs one
|
|
979
|
+
// line and carries on. (`EADDRINUSE` is rare here because of `reuseAddr`, but `EACCES` on a
|
|
980
|
+
// locked-down host is not.)
|
|
981
|
+
note(
|
|
982
|
+
`mDNS: cannot bind ${bindAddress}:${mdnsPort} (${error?.code ?? 'ERROR'}: ${error?.message ?? error})`,
|
|
983
|
+
);
|
|
984
|
+
socket = null;
|
|
985
|
+
return status();
|
|
986
|
+
}
|
|
987
|
+
socket.on('error', (error) =>
|
|
988
|
+
note(`mDNS: socket error (${error?.code ?? 'ERROR'}: ${error?.message ?? error})`),
|
|
989
|
+
);
|
|
990
|
+
socket.on('message', (bytes, rinfo) => handleMessage(bytes, rinfo));
|
|
991
|
+
|
|
992
|
+
// Both of these throw on some platforms (and on some interfaces), and neither is essential: a
|
|
993
|
+
// responder that cannot set its multicast TTL still answers unicast and still announces.
|
|
994
|
+
try {
|
|
995
|
+
socket.setMulticastLoopback(options.multicastLoopback !== false);
|
|
996
|
+
knobs.multicast_loopback = { ok: true, value: options.multicastLoopback !== false, error: null };
|
|
997
|
+
} catch (error) {
|
|
998
|
+
knobs.multicast_loopback = {
|
|
999
|
+
ok: false,
|
|
1000
|
+
value: null,
|
|
1001
|
+
error: `${error?.code ?? 'ERROR'}: ${error?.message ?? error}`,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const ttl = Number.isInteger(options.multicastTtl) ? options.multicastTtl : 255;
|
|
1005
|
+
try {
|
|
1006
|
+
socket.setMulticastTTL(ttl);
|
|
1007
|
+
knobs.multicast_ttl = { ok: true, value: ttl, error: null };
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
knobs.multicast_ttl = {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
value: null,
|
|
1012
|
+
error: `${error?.code ?? 'ERROR'}: ${error?.message ?? error}`,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Every non-internal IPv4 interface, not just the first: a laptop on Wi-Fi and Ethernet at once is
|
|
1017
|
+
// on two links, and joining one of them makes the responder invisible on the other.
|
|
1018
|
+
const candidates = listInterfaces({ includeInternal: options.includeInternalInterfaces === true });
|
|
1019
|
+
for (const entry of candidates) {
|
|
1020
|
+
if (entry.internal === true && options.includeInternalInterfaces !== true) continue;
|
|
1021
|
+
try {
|
|
1022
|
+
socket.addMembership(group, entry.address);
|
|
1023
|
+
memberships.push({ interface: entry.name, address: entry.address, ok: true, error: null });
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
memberships.push({
|
|
1026
|
+
interface: entry.name,
|
|
1027
|
+
address: entry.address,
|
|
1028
|
+
ok: false,
|
|
1029
|
+
error: `${error?.code ?? 'ERROR'}: ${error?.message ?? error}`,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (memberships.length > 0 && memberships.every((entry) => !entry.ok)) {
|
|
1034
|
+
note(
|
|
1035
|
+
`mDNS: no interface accepted a membership in ${group} (${memberships.length} tried); ` +
|
|
1036
|
+
'announcements will go out the default route only, if at all',
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
if (advertisedAddress === null) {
|
|
1040
|
+
const usable = candidates.find((entry) => !entry.internal && isUsableUnicastIpv4(entry.address));
|
|
1041
|
+
advertisedAddress = usable?.address ?? null;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
await announce();
|
|
1045
|
+
if (announceIntervalMs > 0) {
|
|
1046
|
+
timer = setInterval(() => {
|
|
1047
|
+
announce().catch(() => {});
|
|
1048
|
+
}, announceIntervalMs);
|
|
1049
|
+
// A responder must never be the reason a CLI process will not exit.
|
|
1050
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1051
|
+
}
|
|
1052
|
+
return status();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Send the TTL=0 goodbye, stop the timer, and close the socket.
|
|
1057
|
+
*
|
|
1058
|
+
* The goodbye is awaited before the socket closes: a goodbye that never left the machine is worse
|
|
1059
|
+
* than none, because the log would say the peers were told.
|
|
1060
|
+
*
|
|
1061
|
+
* @returns {Promise<object>}
|
|
1062
|
+
*/
|
|
1063
|
+
async function close() {
|
|
1064
|
+
if (closed) return { ok: true, goodbye: goodbyeSent, already_closed: true };
|
|
1065
|
+
closed = true;
|
|
1066
|
+
if (timer !== null) {
|
|
1067
|
+
clearInterval(timer);
|
|
1068
|
+
timer = null;
|
|
1069
|
+
}
|
|
1070
|
+
const goodbye =
|
|
1071
|
+
socket === null && sink === null
|
|
1072
|
+
? { ok: false, error: 'never started' }
|
|
1073
|
+
: await announce({ goodbye: true });
|
|
1074
|
+
await closeSocket(socket);
|
|
1075
|
+
socket = null;
|
|
1076
|
+
return { ok: goodbye.ok === true, goodbye: goodbye.ok === true, error: goodbye.error ?? null };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** A snapshot for `w2m_status`-style surfaces and for tests. */
|
|
1080
|
+
function status() {
|
|
1081
|
+
const bound = socket === null ? null : safeAddress(socket);
|
|
1082
|
+
return {
|
|
1083
|
+
running: socket !== null && !closed,
|
|
1084
|
+
closed,
|
|
1085
|
+
instance,
|
|
1086
|
+
instance_fqdn: instanceFqdn,
|
|
1087
|
+
host,
|
|
1088
|
+
service_type: serviceType,
|
|
1089
|
+
service_port: Number.isInteger(options.servicePort) ? options.servicePort : null,
|
|
1090
|
+
address: advertisedAddress,
|
|
1091
|
+
group,
|
|
1092
|
+
mdns_port: mdnsPort,
|
|
1093
|
+
bind_address: bindAddress,
|
|
1094
|
+
bound,
|
|
1095
|
+
memberships,
|
|
1096
|
+
knobs,
|
|
1097
|
+
announce_interval_ms: announceIntervalMs,
|
|
1098
|
+
announcements: announcedCount,
|
|
1099
|
+
last_announce_at: lastAnnounceAt,
|
|
1100
|
+
replies,
|
|
1101
|
+
refused_packets: refusedPackets,
|
|
1102
|
+
last_refusal: lastRefusal,
|
|
1103
|
+
ignored: { responses: ignoredResponses, opcode: ignoredOpcode, unanswered },
|
|
1104
|
+
goodbye_sent: goodbyeSent,
|
|
1105
|
+
last_error: lastError,
|
|
1106
|
+
errors,
|
|
1107
|
+
started_at: startedAt,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return {
|
|
1112
|
+
start,
|
|
1113
|
+
close,
|
|
1114
|
+
announce,
|
|
1115
|
+
handleMessage,
|
|
1116
|
+
records,
|
|
1117
|
+
matchQuestions,
|
|
1118
|
+
status,
|
|
1119
|
+
/** The instance FQDN this responder answers for. */
|
|
1120
|
+
get instanceFqdn() {
|
|
1121
|
+
return instanceFqdn;
|
|
1122
|
+
},
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** `socket.address()` throws once the socket is closed; a status read must not. */
|
|
1127
|
+
function safeAddress(socket) {
|
|
1128
|
+
try {
|
|
1129
|
+
const value = socket.address();
|
|
1130
|
+
return { address: value.address, port: value.port, family: value.family };
|
|
1131
|
+
} catch {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ---------------------------------------------------------------------------------------------
|
|
1137
|
+
// Discovery
|
|
1138
|
+
// ---------------------------------------------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
/** Wait, or give up early when the caller aborts. */
|
|
1141
|
+
function waitFor(ms, signal) {
|
|
1142
|
+
return new Promise((resolve) => {
|
|
1143
|
+
if (signal?.aborted) {
|
|
1144
|
+
resolve('aborted');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const timer = setTimeout(() => {
|
|
1148
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
1149
|
+
resolve('elapsed');
|
|
1150
|
+
}, ms);
|
|
1151
|
+
function onAbort() {
|
|
1152
|
+
clearTimeout(timer);
|
|
1153
|
+
resolve('aborted');
|
|
1154
|
+
}
|
|
1155
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* One bounded mDNS scan for W2M service instances.
|
|
1161
|
+
*
|
|
1162
|
+
* A fresh socket per call, bound to an ephemeral port: RFC 6762 §5.1 requires a one-shot query *not*
|
|
1163
|
+
* to come from port 5353, and a fresh socket also means a scan cannot be poisoned by the previous
|
|
1164
|
+
* scan's socket state. The socket is closed on every path, including a throw from a caller's abort
|
|
1165
|
+
* signal, and the whole function resolves — it never rejects.
|
|
1166
|
+
*
|
|
1167
|
+
* What comes back is *evidence*, not a verdict: records as the wire carried them, reassembled into
|
|
1168
|
+
* instances, with the address each packet was observed to come from. Nothing here is verified, and
|
|
1169
|
+
* nothing here may be dispatched to without the gates in `src/plugin/mdns-discover.mjs`.
|
|
1170
|
+
*
|
|
1171
|
+
* @param {object} [options]
|
|
1172
|
+
* @param {string} [options.serviceType]
|
|
1173
|
+
* @param {number} [options.timeoutMs] Window, clamped to 50..10000 ms.
|
|
1174
|
+
* @param {string} [options.group]
|
|
1175
|
+
* @param {number} [options.mdnsPort]
|
|
1176
|
+
* @param {Array<{address: string, port: number}>} [options.targets] Where the query goes. Defaults to
|
|
1177
|
+
* the multicast group:5353. A test, or a network where multicast is filtered, can name a unicast
|
|
1178
|
+
* target instead — the query is identically shaped either way.
|
|
1179
|
+
* @param {string} [options.bindAddress]
|
|
1180
|
+
* @param {boolean} [options.unicastResponse] Set the QU bit (RFC 6762 §5.4).
|
|
1181
|
+
* @param {AbortSignal} [options.signal]
|
|
1182
|
+
* @param {() => number} [options.now]
|
|
1183
|
+
* @returns {Promise<object>}
|
|
1184
|
+
*/
|
|
1185
|
+
export async function discoverServices(options = {}) {
|
|
1186
|
+
const serviceType = options.serviceType ?? DEFAULT_SERVICE_TYPE;
|
|
1187
|
+
const group = options.group ?? MDNS_GROUP;
|
|
1188
|
+
const mdnsPort = Number.isInteger(options.mdnsPort) ? options.mdnsPort : MDNS_PORT;
|
|
1189
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
1190
|
+
const requested = Number.isFinite(options.timeoutMs)
|
|
1191
|
+
? Number(options.timeoutMs)
|
|
1192
|
+
: DEFAULT_DISCOVERY_TIMEOUT_MS;
|
|
1193
|
+
const timeoutMs = Math.min(10_000, Math.max(50, Math.round(requested)));
|
|
1194
|
+
const targets =
|
|
1195
|
+
Array.isArray(options.targets) && options.targets.length > 0
|
|
1196
|
+
? options.targets.map((target) => ({ address: target.address, port: Number(target.port) }))
|
|
1197
|
+
: [{ address: group, port: mdnsPort }];
|
|
1198
|
+
|
|
1199
|
+
const startedAt = now();
|
|
1200
|
+
const cache = createRecordCache({ now });
|
|
1201
|
+
const queries = [];
|
|
1202
|
+
const packets = { received: 0, refused: 0, ignored: 0, answers: 0, answers_without_records: 0 };
|
|
1203
|
+
const refusals = [];
|
|
1204
|
+
/** @type {import('node:dgram').Socket|null} */
|
|
1205
|
+
let bound = null;
|
|
1206
|
+
let boundTo = null;
|
|
1207
|
+
let lastRefusal = null;
|
|
1208
|
+
let socketError = null;
|
|
1209
|
+
let outcome = 'elapsed';
|
|
1210
|
+
|
|
1211
|
+
try {
|
|
1212
|
+
// `encodeQuery` can only throw on our own arguments, and a bad service type is a caller bug worth
|
|
1213
|
+
// reporting as a failed scan rather than an exception out of a tool.
|
|
1214
|
+
const query = encodeQuery({ serviceType, unicastResponse: options.unicastResponse === true });
|
|
1215
|
+
try {
|
|
1216
|
+
bound = await bindUdpSocket({ address: options.bindAddress ?? '0.0.0.0', port: 0 });
|
|
1217
|
+
boundTo = safeAddress(bound);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
socketError = `${error?.code ?? 'ERROR'}: ${error?.message ?? error}`;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (bound !== null) {
|
|
1223
|
+
bound.on('error', (error) => {
|
|
1224
|
+
socketError = `${error?.code ?? 'ERROR'}: ${error?.message ?? error}`;
|
|
1225
|
+
});
|
|
1226
|
+
// Responses to a one-shot query are unicast back to this socket (§5.1/§6.7), but a responder on
|
|
1227
|
+
// the same link may also multicast its answer, which arrives here too because the socket joined
|
|
1228
|
+
// nothing and is simply addressed. Both are handled the same way, and both are hints.
|
|
1229
|
+
bound.on('message', (bytes, rinfo) => {
|
|
1230
|
+
packets.received += 1;
|
|
1231
|
+
const decoded = decodeMessage(bytes);
|
|
1232
|
+
if (!decoded.ok) {
|
|
1233
|
+
packets.refused += 1;
|
|
1234
|
+
lastRefusal = decoded.reason;
|
|
1235
|
+
if (refusals.length < 10) refusals.push({ reason: decoded.reason, from: rinfo?.address ?? null });
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (!decoded.message.flags.qr) {
|
|
1239
|
+
// Another host's query. Nothing in a bounded scan needs to read it.
|
|
1240
|
+
packets.ignored += 1;
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const records = decoded.message.records;
|
|
1244
|
+
if (records.length === 0) {
|
|
1245
|
+
packets.answers_without_records += 1;
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
packets.answers += 1;
|
|
1249
|
+
const at = now();
|
|
1250
|
+
for (const record of records) {
|
|
1251
|
+
cache.apply(record, { at, observed_address: rinfo?.address ?? null });
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
for (const target of targets) {
|
|
1255
|
+
const sent = await sendDatagram(bound, query, target);
|
|
1256
|
+
queries.push({ ...sent, at: now() });
|
|
1257
|
+
}
|
|
1258
|
+
outcome = await waitFor(timeoutMs, options.signal);
|
|
1259
|
+
}
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
socketError = socketError ?? `${error?.code ?? 'ERROR'}: ${error?.message ?? error}`;
|
|
1262
|
+
} finally {
|
|
1263
|
+
await closeSocket(bound);
|
|
1264
|
+
bound = null;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const live = cache.live();
|
|
1268
|
+
const assembled = parseServiceInstances(live, { serviceType });
|
|
1269
|
+
const sentOk = queries.filter((query) => query.ok);
|
|
1270
|
+
const available = sentOk.length > 0;
|
|
1271
|
+
const elapsedMs = now() - startedAt;
|
|
1272
|
+
let error = null;
|
|
1273
|
+
if (!available) {
|
|
1274
|
+
error =
|
|
1275
|
+
socketError !== null
|
|
1276
|
+
? `MDNS_UNAVAILABLE: no mDNS query could be sent (${socketError})`
|
|
1277
|
+
: 'MDNS_UNAVAILABLE: no mDNS query could be sent to any target';
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return {
|
|
1281
|
+
ok: available,
|
|
1282
|
+
available,
|
|
1283
|
+
error,
|
|
1284
|
+
service_type: serviceType,
|
|
1285
|
+
group,
|
|
1286
|
+
mdns_port: mdnsPort,
|
|
1287
|
+
socket: boundTo,
|
|
1288
|
+
targets,
|
|
1289
|
+
queries,
|
|
1290
|
+
sent: sentOk.length,
|
|
1291
|
+
elapsed_ms: elapsedMs,
|
|
1292
|
+
aborted: outcome === 'aborted',
|
|
1293
|
+
timeout_ms: timeoutMs,
|
|
1294
|
+
instances: assembled.instances,
|
|
1295
|
+
truncated: assembled.truncated,
|
|
1296
|
+
records: live.map((entry) => entry.record),
|
|
1297
|
+
packets,
|
|
1298
|
+
refusals,
|
|
1299
|
+
last_refusal: lastRefusal,
|
|
1300
|
+
counts: {
|
|
1301
|
+
instances: assembled.instances.length,
|
|
1302
|
+
records: live.length,
|
|
1303
|
+
refused_packets: packets.refused,
|
|
1304
|
+
...assembled.counts,
|
|
1305
|
+
},
|
|
1306
|
+
note: available
|
|
1307
|
+
? `queried ${serviceType} on ${targets.map((target) => `${target.address}:${target.port}`).join(', ')} for ${elapsedMs} ms` +
|
|
1308
|
+
(assembled.instances.length === 0 ? '; no peer answered - that is not evidence that none exists' : '')
|
|
1309
|
+
: error,
|
|
1310
|
+
};
|
|
1311
|
+
}
|