@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.
@@ -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
+ }