@twinsearth/w2m-dsh-plugin 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * LAN discovery for the W2M plugin: mDNS candidates, offered *next to* the relay roster.
3
+ *
4
+ * # THE RULE, and why it is written here in these words
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 a defect, not a shortcut.
10
+ *
11
+ * This module is where that rule has to be *executable* rather than aspirational, so it is spelled out
12
+ * as code in {@link dispatchRefusal}:
13
+ *
14
+ * 1. mDNS carries no proof of anything. Every candidate is reported `verified: false`, and no field
15
+ * of a discovered record is ever copied into a token, a URL credential, or a config value.
16
+ * 2. A **service name is not identity.** A matching name is reported as `name_match`, a hint a human
17
+ * can read, and can never on its own promote a candidate. Identity is a `machine_id` the *relay*
18
+ * assigned at pairing time, which is the only value that can be matched against the roster.
19
+ * 3. A candidate the relay does not list is `dispatchable: false` with a refusal that names what
20
+ * would change it. It is visible — an operator should see the machine that is on the LAN but
21
+ * unpaired — and it is not selectable.
22
+ * 4. Even a paired, relay-listed candidate is only a *dispatch target* in the sense the relay
23
+ * already allows: this module does not add authority, it removes ambiguity.
24
+ *
25
+ * What this buys, concretely: a device that shows itself on the LAN without pairing is listed as
26
+ * *unpaired* rather than silently appearing in the fleet, and no code path here can turn "I saw it" into
27
+ * "I may run there".
28
+ *
29
+ * # Fail soft, but never silently
30
+ *
31
+ * Multicast is unavailable on plenty of real networks — a locked-down corporate WLAN, a container with
32
+ * no multicast route, a host where every interface refuses the membership. That is not an error
33
+ * condition for the plugin: `createDiscoverySource` logs **one** line naming the reason (repeat
34
+ * failures with the same reason are not logged again) and every call still returns a structure with
35
+ * `available: false` and `error` set. A caller can therefore always tell "nothing was discovered"
36
+ * apart from "nothing could be discovered", which is the distinction a silent success would destroy.
37
+ */
38
+
39
+ import {
40
+ DEFAULT_DISCOVERY_TIMEOUT_MS,
41
+ DEFAULT_SERVICE_TYPE,
42
+ createResponder,
43
+ discoverServices,
44
+ } from '../agent/mdns.mjs';
45
+ import { isUsableUnicastIpv4 } from '../agent/mdns-wire.mjs';
46
+ // The liveness rule is the relay control's own (`fleet-target.mjs`), imported rather than re-derived:
47
+ // "is this machine online" must mean one thing across the plugin, and a second copy of the rule here
48
+ // would be a second chance to disagree about who is reachable.
49
+ import { isSelectableDevice } from './fleet-target.mjs';
50
+
51
+ /** The `source` value every entry produced by this module carries. */
52
+ export const DISCOVERY_SOURCE = 'mdns';
53
+
54
+ /** TXT key carrying the relay's machine id. The one field that can be matched against the roster. */
55
+ export const TXT_MACHINE_ID = 'mid';
56
+
57
+ /** TXT key carrying the machine's display name. */
58
+ export const TXT_MACHINE_NAME = 'name';
59
+
60
+ /**
61
+ * The sentence every discovered candidate carries, so a reader of the JSON cannot miss the posture.
62
+ *
63
+ * It is a constant rather than a literal per site because a model reading `w2m_devices` output sees the
64
+ * same rule whether it looked at the block, the candidate, or only the counts.
65
+ */
66
+ export const DISCOVERY_NOTE =
67
+ 'discovery is a hint, never an authorisation: a discovered device is unverified (mDNS authenticates ' +
68
+ 'nothing) and is only offered as a dispatch target when the relay already lists it as a live machine - ' +
69
+ 'pairing, the device token, the allow-list and the anchors all still apply';
70
+
71
+ /** Why a candidate may not be dispatched to. Each message says what would change it. */
72
+ export const DISPATCH_REFUSAL = Object.freeze({
73
+ NOT_DISCOVERED: 'MDNS_NOT_DISCOVERED',
74
+ IDENTITY_UNKNOWN: 'MDNS_IDENTITY_UNKNOWN',
75
+ UNPAIRED: 'MDNS_UNPAIRED',
76
+ RELAY_STALE: 'MDNS_RELAY_STALE',
77
+ LOCAL_INSTANCE: 'MDNS_LOCAL_INSTANCE',
78
+ NO_ENDPOINT: 'MDNS_NO_ENDPOINT',
79
+ });
80
+
81
+ /** Lower-case a name for comparison. Names arrive from a human, so case and padding are noise. */
82
+ function fold(value) {
83
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
84
+ }
85
+
86
+ /** A trimmed non-empty string, or null. */
87
+ function text(value) {
88
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
89
+ }
90
+
91
+ /**
92
+ * Index the relay roster for the two lookups this module needs.
93
+ *
94
+ * `byId` is the only index that can establish identity: a machine id the relay minted at pairing time.
95
+ * `byName` exists so a *hint* can be shown — "this looks like the machine the relay calls
96
+ * `mac-studio`" — and is deliberately a separate structure so no future edit can accidentally use it
97
+ * where identity is required.
98
+ *
99
+ * @param {object[]} relayRecords `GET /v1/devices`'s `devices`, verbatim.
100
+ * @returns {{byId: Map<string, object>, byName: Map<string, object[]>, size: number}}
101
+ */
102
+ export function buildRelayIndex(relayRecords) {
103
+ const byId = new Map();
104
+ const byName = new Map();
105
+ let size = 0;
106
+ for (const record of Array.isArray(relayRecords) ? relayRecords : []) {
107
+ if (record === null || typeof record !== 'object') continue;
108
+ size += 1;
109
+ const machineId = text(record.machine_id);
110
+ const name = text(record.machine_name) ?? text(record.name);
111
+ const entry = {
112
+ machine_id: machineId,
113
+ name,
114
+ online: isSelectableDevice(record),
115
+ state: text(record.state) ?? (isSelectableDevice(record) ? 'online' : 'unknown'),
116
+ };
117
+ if (machineId !== null && !byId.has(machineId)) byId.set(machineId, entry);
118
+ const key = fold(name);
119
+ if (key !== '') {
120
+ if (!byName.has(key)) byName.set(key, []);
121
+ byName.get(key).push(entry);
122
+ }
123
+ }
124
+ return { byId, byName, size };
125
+ }
126
+
127
+ /**
128
+ * Why a discovered candidate may not be dispatched to, or `null` when it may.
129
+ *
130
+ * This function **is** the rule. Read it as the list of things a discovered device would have to fake to
131
+ * become a dispatch target: an identity the relay assigned, a live listing in the relay's own roster,
132
+ * and an endpoint. A name it chose for itself is in none of those.
133
+ *
134
+ * @param {object|null|undefined} candidate A candidate from {@link describeDiscoveredCandidate}.
135
+ * @returns {string|null}
136
+ */
137
+ export function dispatchRefusal(candidate) {
138
+ if (candidate === null || typeof candidate !== 'object') {
139
+ return `${DISPATCH_REFUSAL.NOT_DISCOVERED}: not a discovered candidate at all`;
140
+ }
141
+ if (candidate.source !== DISCOVERY_SOURCE) {
142
+ return `${DISPATCH_REFUSAL.NOT_DISCOVERED}: this is not a LAN-discovered candidate, so this rule does not speak for it`;
143
+ }
144
+ if (text(candidate.machine_id) === null) {
145
+ return (
146
+ `${DISPATCH_REFUSAL.IDENTITY_UNKNOWN}: the advertisement carries no \`${TXT_MACHINE_ID}\` key, so ` +
147
+ 'there is no relay machine id to look up; a service name is not identity and is never used as one. ' +
148
+ 'Pair the machine (its agent then advertises the machine id the relay gave it)'
149
+ );
150
+ }
151
+ if (candidate.known_to_relay !== true) {
152
+ return (
153
+ `${DISPATCH_REFUSAL.UNPAIRED}: the relay does not list machine \`${candidate.machine_id}\`, so nothing ` +
154
+ 'has authorised this machine; a discovered device is a candidate to ask, never an authorisation. ' +
155
+ 'Pair it, or start its agent so the relay holds a lease, and it becomes an ordinary fleet member'
156
+ );
157
+ }
158
+ if (candidate.relay_online !== true) {
159
+ return (
160
+ `${DISPATCH_REFUSAL.RELAY_STALE}: the relay lists machine \`${candidate.machine_id}\` but its lease is not ` +
161
+ `live (state: ${candidate.relay_state ?? 'unknown'}), so a dispatch narrowed to it would have no target; ` +
162
+ 'wait for the machine to come online'
163
+ );
164
+ }
165
+ if (candidate.local === true) {
166
+ return `${DISPATCH_REFUSAL.LOCAL_INSTANCE}: this is this machine's own advertisement, not a peer`;
167
+ }
168
+ if (!Number.isInteger(candidate.port) || candidate.port < 1 || candidate.port > 65535) {
169
+ return `${DISPATCH_REFUSAL.NO_ENDPOINT}: the advertisement named no usable port (SRV port ${JSON.stringify(candidate.port)})`;
170
+ }
171
+ if (text(candidate.address) === null) {
172
+ return `${DISPATCH_REFUSAL.NO_ENDPOINT}: the advertisement named no usable address (A record or observed source)`;
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * May this candidate be offered as a dispatch target?
179
+ *
180
+ * @param {object|null|undefined} candidate
181
+ * @returns {boolean}
182
+ */
183
+ export function mayDispatchTo(candidate) {
184
+ return dispatchRefusal(candidate) === null;
185
+ }
186
+
187
+ /**
188
+ * The candidates a dispatch surface may offer.
189
+ *
190
+ * A caller that builds a target list from anything else has bypassed the rule; this exists so the
191
+ * correct list is the easy one to build.
192
+ *
193
+ * @param {object[]} candidates
194
+ * @returns {object[]}
195
+ */
196
+ export function dispatchableCandidates(candidates) {
197
+ return (Array.isArray(candidates) ? candidates : []).filter((candidate) => mayDispatchTo(candidate));
198
+ }
199
+
200
+ /**
201
+ * Turn one reassembled mDNS instance into a candidate with its provenance and its verdict.
202
+ *
203
+ * @param {object} instance One entry of {@link discoverServices}'s `instances`.
204
+ * @param {object} context
205
+ * @param {{byId: Map<string, object>, byName: Map<string, object[]>}} context.relayIndex
206
+ * @param {string|null} [context.localMachineId] This machine's relay id, for the "this is me" mark.
207
+ * @param {number|null} [context.observedAt]
208
+ * @returns {object}
209
+ */
210
+ export function describeDiscoveredCandidate(instance, context) {
211
+ const relayIndex = context?.relayIndex ?? { byId: new Map(), byName: new Map() };
212
+ const localMachineId = text(context?.localMachineId);
213
+ const txt = instance?.txt?.map ?? {};
214
+ const machineId = text(txt[TXT_MACHINE_ID]);
215
+ const relay = machineId !== null ? (relayIndex.byId.get(machineId) ?? null) : null;
216
+ const nameKey = fold(instance?.name);
217
+ const nameMatches = nameKey === '' ? [] : (relayIndex.byName.get(nameKey) ?? []);
218
+
219
+ const advertised = Array.isArray(instance?.addresses)
220
+ ? instance.addresses.filter((value) => typeof value === 'string')
221
+ : [];
222
+ const usableAdvertised = advertised.find((value) => isUsableUnicastIpv4(value)) ?? null;
223
+ const observed = text(instance?.observed_address);
224
+ const usableObserved = observed !== null && isUsableUnicastIpv4(observed) ? observed : null;
225
+ const address = usableAdvertised ?? usableObserved;
226
+
227
+ const candidate = {
228
+ source: DISCOVERY_SOURCE,
229
+ // Never true, for any candidate, ever: mDNS cannot verify anything. The field exists so a reader
230
+ // does not have to infer the posture from a missing key.
231
+ verified: false,
232
+ name: text(instance?.name) ?? text(instance?.instance) ?? null,
233
+ instance: text(instance?.instance) ?? null,
234
+ service_type: text(instance?.service_type) ?? null,
235
+ host: text(instance?.host) ?? null,
236
+ address,
237
+ address_source: usableAdvertised !== null ? 'a' : usableObserved !== null ? 'observed' : null,
238
+ addresses: advertised,
239
+ observed_address: observed,
240
+ port: Number.isInteger(instance?.port) ? instance.port : null,
241
+ // The relay's machine id as *claimed* by the advertisement. Used to look the machine up in the
242
+ // roster, never trusted on its own: the roster entry is what makes it mean anything.
243
+ machine_id: machineId,
244
+ machine_name: text(txt[TXT_MACHINE_NAME]),
245
+ known_to_relay: relay !== null,
246
+ relay_state: relay?.state ?? null,
247
+ relay_online: relay === null ? null : relay.online === true,
248
+ // A name match is a *hint for a human*, never a promotion. Reported separately so it cannot be
249
+ // mistaken for `known_to_relay`, which is decided by machine id alone.
250
+ name_match:
251
+ machineId === null && nameMatches.length === 1
252
+ ? { machine_id: nameMatches[0].machine_id, name: nameMatches[0].name, online: nameMatches[0].online }
253
+ : null,
254
+ local: machineId !== null && localMachineId !== null && machineId === localMachineId,
255
+ ttl: instance?.ttl ?? null,
256
+ superseded_srv: Number.isInteger(instance?.superseded_srv) ? instance.superseded_srv : 0,
257
+ seen_at: Number.isFinite(context?.observedAt) ? context.observedAt : null,
258
+ dispatchable: false,
259
+ refusal: null,
260
+ note: DISCOVERY_NOTE,
261
+ };
262
+ candidate.refusal = dispatchRefusal(candidate);
263
+ candidate.dispatchable = candidate.refusal === null;
264
+ return candidate;
265
+ }
266
+
267
+ /**
268
+ * Assemble the block `w2m_devices` (and a status surface) reports.
269
+ *
270
+ * @param {object} input
271
+ * @param {object[]} [input.instances] From {@link discoverServices}.
272
+ * @param {object[]} [input.relayRecords] `GET /v1/devices`'s `devices`, for the "does the relay list it" question.
273
+ * @param {string|null} [input.localMachineId]
274
+ * @param {boolean} [input.available] Whether a scan could actually be sent.
275
+ * @param {string|null} [input.error] Why not, when it could not.
276
+ * @param {number|null} [input.scannedAt]
277
+ * @param {number|null} [input.elapsedMs]
278
+ * @param {boolean} [input.aborted]
279
+ * @param {object|null} [input.packets]
280
+ * @param {number} [input.truncated]
281
+ * @returns {object}
282
+ */
283
+ export function describeDiscovered(input = {}) {
284
+ const relayRecords = Array.isArray(input.relayRecords) ? input.relayRecords : [];
285
+ const relayIndex = buildRelayIndex(relayRecords);
286
+ const observedAt = Number.isFinite(input.scannedAt) ? input.scannedAt : null;
287
+ const instances = Array.isArray(input.instances) ? input.instances : [];
288
+ const candidates = instances.map((instance) =>
289
+ describeDiscoveredCandidate(instance, {
290
+ relayIndex,
291
+ localMachineId: input.localMachineId ?? null,
292
+ observedAt,
293
+ }),
294
+ );
295
+ const counts = {
296
+ instances: candidates.length,
297
+ relay_listed: candidates.filter((candidate) => candidate.known_to_relay).length,
298
+ // Every candidate we cannot even ask the relay about, plus every one it does not know: both are
299
+ // "not a dispatch target", and both are reported so the number is never quietly zero.
300
+ unpaired: candidates.filter((candidate) => candidate.machine_id === null || !candidate.known_to_relay)
301
+ .length,
302
+ dispatchable: candidates.filter((candidate) => candidate.dispatchable).length,
303
+ local: candidates.filter((candidate) => candidate.local).length,
304
+ verified: 0,
305
+ };
306
+ return {
307
+ source: DISCOVERY_SOURCE,
308
+ service_type: text(input.serviceType) ?? DEFAULT_SERVICE_TYPE,
309
+ // Three states, kept apart on purpose: true (a scan ran and queries went out), false (a scan ran
310
+ // and multicast was unavailable), null (nobody has asked yet). Collapsing the last two would turn
311
+ // "not asked" into a claim about the network.
312
+ available: input.available === true ? true : input.available === false ? false : null,
313
+ error: input.error ?? null,
314
+ scanned_at: observedAt,
315
+ elapsed_ms: Number.isFinite(input.elapsedMs) ? input.elapsedMs : null,
316
+ aborted: input.aborted === true,
317
+ truncated: Number.isInteger(input.truncated) ? input.truncated : 0,
318
+ packets: input.packets ?? null,
319
+ counts,
320
+ candidates,
321
+ relay_index_size: relayIndex.size,
322
+ note: DISCOVERY_NOTE,
323
+ ...(input.available === true
324
+ ? {}
325
+ : {
326
+ unavailable_note:
327
+ input.available === false
328
+ ? 'no scan was possible, so an empty candidate list here means "unknown", not "no devices on the LAN"'
329
+ : 'no scan has been asked for yet; ask for one and an empty candidate list then means the same thing',
330
+ }),
331
+ };
332
+ }
333
+
334
+ /**
335
+ * One list showing both sources, with provenance, for a UI or a status surface.
336
+ *
337
+ * Dispatchability is reported per source and is deliberately *not* uniform:
338
+ *
339
+ * * a **relay** entry keeps the relay's own liveness verdict — nothing in this module narrows or
340
+ * widens what the relay path already allows;
341
+ * * a **discovered** entry is additionally required to be listed by the relay (see
342
+ * {@link dispatchRefusal}), which is the only reason it can ever be `dispatchable`.
343
+ *
344
+ * @param {object} input
345
+ * @param {object[]} [input.relayRecords]
346
+ * @param {object[]} [input.candidates] Already described by {@link describeDiscovered}.
347
+ * @param {string|null} [input.localMachineId]
348
+ * @returns {{roster: object[], counts: object, note: string}}
349
+ */
350
+ export function mergeRosterView(input = {}) {
351
+ const localMachineId = text(input.localMachineId);
352
+ const relayed = (Array.isArray(input.relayRecords) ? input.relayRecords : [])
353
+ .filter((record) => record !== null && typeof record === 'object')
354
+ .map((record) => {
355
+ const machineId = text(record.machine_id);
356
+ return {
357
+ source: 'relay',
358
+ verified: true,
359
+ machine_id: machineId,
360
+ name: text(record.machine_name) ?? text(record.name),
361
+ address: null,
362
+ port: null,
363
+ online: isSelectableDevice(record),
364
+ state: text(record.state) ?? null,
365
+ local: machineId !== null && localMachineId !== null && machineId === localMachineId,
366
+ relay_listed: true,
367
+ dispatchable: isSelectableDevice(record),
368
+ refusal: isSelectableDevice(record)
369
+ ? null
370
+ : `RELAY_STALE: the relay lists machine \`${machineId ?? 'unknown'}\` but its lease is not live`,
371
+ };
372
+ });
373
+ const discovered = (Array.isArray(input.candidates) ? input.candidates : []).map((candidate) => ({
374
+ source: DISCOVERY_SOURCE,
375
+ verified: false,
376
+ machine_id: candidate.machine_id ?? null,
377
+ name: candidate.name ?? null,
378
+ address: candidate.address ?? null,
379
+ port: candidate.port ?? null,
380
+ online: candidate.relay_online === true,
381
+ state: candidate.relay_state ?? null,
382
+ local: candidate.local === true,
383
+ relay_listed: candidate.known_to_relay === true,
384
+ dispatchable: candidate.dispatchable === true,
385
+ refusal: candidate.refusal ?? null,
386
+ }));
387
+ return {
388
+ roster: [...relayed, ...discovered],
389
+ counts: {
390
+ relay: relayed.length,
391
+ discovered: discovered.length,
392
+ dispatchable_relay: relayed.filter((entry) => entry.dispatchable).length,
393
+ dispatchable_discovered: discovered.filter((entry) => entry.dispatchable).length,
394
+ unpaired_discovered: discovered.filter((entry) => entry.relay_listed !== true).length,
395
+ },
396
+ note: DISCOVERY_NOTE,
397
+ };
398
+ }
399
+
400
+ /**
401
+ * The plugin's discovery source: one object owning the scan, the status, and the opt-in responder.
402
+ *
403
+ * @param {object} [options]
404
+ * @param {(options: object) => Promise<object>} [options.scan] Injected scanner (tests use this).
405
+ * @param {string} [options.serviceType]
406
+ * @param {number} [options.timeoutMs]
407
+ * @param {{warn?: Function, info?: Function}|null} [options.logger]
408
+ * @param {() => number} [options.now]
409
+ * @param {object|null} [options.advertise] Opt-in responder settings; `null` (the default) means this
410
+ * machine never binds 5353 and never announces. See {@link createDiscoverySource}'s `start()`.
411
+ * @returns {object}
412
+ */
413
+ export function createDiscoverySource(options = {}) {
414
+ const scanner = typeof options.scan === 'function' ? options.scan : discoverServices;
415
+ const serviceType = options.serviceType ?? DEFAULT_SERVICE_TYPE;
416
+ const timeoutMs = Number.isFinite(options.timeoutMs)
417
+ ? Number(options.timeoutMs)
418
+ : DEFAULT_DISCOVERY_TIMEOUT_MS;
419
+ const logger = options.logger ?? null;
420
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
421
+ const advertise = options.advertise ?? null;
422
+
423
+ const state = {
424
+ scans: 0,
425
+ last_scan_at: null,
426
+ last_error: null,
427
+ available: null,
428
+ instances: [],
429
+ logged: null,
430
+ advertising: false,
431
+ };
432
+ /** @type {{start: Function, close: Function, status: Function}|null} */
433
+ let responder = null;
434
+
435
+ /** One line per distinct failure: a locked-down network must not fill a log with the same sentence. */
436
+ function logOnce(line) {
437
+ if (state.logged === line) return;
438
+ state.logged = line;
439
+ try {
440
+ if (typeof logger?.warn === 'function') logger.warn(line);
441
+ else if (typeof logger?.info === 'function') logger.info(line);
442
+ } catch {
443
+ // A logger that throws is not allowed to break discovery.
444
+ }
445
+ }
446
+
447
+ /** The current view, without scanning: recomputed against the roster it is given. */
448
+ function describe(context = {}) {
449
+ return describeDiscovered({
450
+ instances: state.instances,
451
+ relayRecords: context.relayRecords ?? [],
452
+ localMachineId: context.localMachineId ?? null,
453
+ available: state.available === true ? true : state.available === false ? false : null,
454
+ error: state.last_error,
455
+ scannedAt: state.last_scan_at,
456
+ elapsedMs: context.elapsedMs ?? null,
457
+ aborted: context.aborted === true,
458
+ packets: context.packets ?? null,
459
+ truncated: context.truncated ?? 0,
460
+ serviceType,
461
+ });
462
+ }
463
+
464
+ /**
465
+ * Run one bounded scan and describe the result against the relay roster.
466
+ *
467
+ * Never throws and never rejects: a scanner that fails is a scan that is `available: false`, which the
468
+ * caller reports alongside the relay roster it already has.
469
+ *
470
+ * @param {{signal?: AbortSignal, relayRecords?: object[], localMachineId?: string|null}} [context]
471
+ * @returns {Promise<object>}
472
+ */
473
+ async function scan(context = {}) {
474
+ state.scans += 1;
475
+ let result = null;
476
+ try {
477
+ result = await scanner({ serviceType, timeoutMs, signal: context.signal });
478
+ } catch (error) {
479
+ result = {
480
+ ok: false,
481
+ available: false,
482
+ error: `MDNS_SCAN_FAILED: ${error instanceof Error ? error.message : String(error)}`,
483
+ instances: [],
484
+ };
485
+ }
486
+ const available = result?.available === true;
487
+ state.last_scan_at = now();
488
+ state.available = available;
489
+ state.last_error = available
490
+ ? null
491
+ : (result?.error ?? 'MDNS_UNAVAILABLE: the scan reported neither success nor a reason');
492
+ state.instances = Array.isArray(result?.instances) ? result.instances : [];
493
+ if (!available) {
494
+ logOnce(
495
+ `w2m: LAN discovery is unavailable (${state.last_error}); continuing without discovered candidates`,
496
+ );
497
+ }
498
+ return describe({
499
+ relayRecords: context.relayRecords ?? [],
500
+ localMachineId: context.localMachineId ?? null,
501
+ elapsedMs: result?.elapsed_ms ?? null,
502
+ aborted: result?.aborted === true,
503
+ packets: result?.packets ?? null,
504
+ truncated: result?.truncated ?? 0,
505
+ });
506
+ }
507
+
508
+ /**
509
+ * Start the opt-in responder.
510
+ *
511
+ * Opt-in, not automatic: binding a multicast port and announcing on the LAN is a side effect on the
512
+ * host's network that nobody asked for by loading a plugin, and `w2m_devices` works without it. When
513
+ * it is asked for, a failure is soft — one log line, `advertising: false` in {@link status}, and
514
+ * everything else keeps working.
515
+ *
516
+ * @returns {Promise<object>} {@link status}.
517
+ */
518
+ async function start() {
519
+ if (advertise === null || responder !== null) return status();
520
+ try {
521
+ responder = createResponder({
522
+ ...advertise,
523
+ serviceType,
524
+ logger: (line) => logOnce(line),
525
+ });
526
+ const started = await responder.start();
527
+ state.advertising = started?.running === true;
528
+ if (!state.advertising) {
529
+ logOnce(`w2m: mDNS advertising is off (${started?.last_error ?? 'the responder did not start'})`);
530
+ }
531
+ } catch (error) {
532
+ responder = null;
533
+ state.advertising = false;
534
+ logOnce(
535
+ `w2m: mDNS advertising failed (${error instanceof Error ? error.message : String(error)}); continuing`,
536
+ );
537
+ }
538
+ return status();
539
+ }
540
+
541
+ /** Stop the responder, sending the TTL=0 goodbye. Never throws. */
542
+ async function stop() {
543
+ if (responder === null) return status();
544
+ const closing = responder;
545
+ responder = null;
546
+ state.advertising = false;
547
+ try {
548
+ await closing.close();
549
+ } catch (error) {
550
+ logOnce(`w2m: mDNS shutdown failed (${error instanceof Error ? error.message : String(error)})`);
551
+ }
552
+ return status();
553
+ }
554
+
555
+ /**
556
+ * The status surface: what has been scanned, whether it worked, and whether we are advertising.
557
+ *
558
+ * @returns {object}
559
+ */
560
+ function status() {
561
+ return {
562
+ source: DISCOVERY_SOURCE,
563
+ service_type: serviceType,
564
+ timeout_ms: timeoutMs,
565
+ scans: state.scans,
566
+ available: state.available,
567
+ last_error: state.last_error,
568
+ last_scan_at: state.last_scan_at,
569
+ candidates: state.instances.length,
570
+ advertising: state.advertising,
571
+ advertising_requested: advertise !== null,
572
+ note: DISCOVERY_NOTE,
573
+ };
574
+ }
575
+
576
+ return { scan, describe, status, start, stop };
577
+ }
@@ -97,6 +97,9 @@ import {
97
97
  sessionKeyFor,
98
98
  TARGET_ROUTE_PATH,
99
99
  } from './fleet-target.mjs';
100
+ // v0.7.0: the LAN hint layer. Imported here and used in exactly one place (`w2m_devices`); see the
101
+ // module header for why a discovered device is a candidate to *ask* and never an authorisation.
102
+ import { createDiscoverySource } from './mdns-discover.mjs';
100
103
  import { loadSharedConfig, describeSharedConfig } from './shared-config.mjs';
101
104
  import { DEFAULT_DAILY_TIMES, DEFAULT_TIME_ZONE } from './schedule.mjs';
102
105
 
@@ -131,7 +134,7 @@ const MAX_STAGES = 16;
131
134
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
132
135
  * overrides it at runtime for tests and packagers.
133
136
  */
134
- const PLUGIN_VERSION = '0.5.5';
137
+ const PLUGIN_VERSION = '0.5.6';
135
138
 
136
139
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
137
140
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -2886,6 +2889,11 @@ export async function apply(ctx, config = {}) {
2886
2889
  resolveToken,
2887
2890
  });
2888
2891
 
2892
+ // The LAN discovery source (v0.7.0). Constructing it binds nothing: a socket is opened only when a
2893
+ // scan is asked for, and only `w2m_devices` asks. A network where multicast is unavailable produces
2894
+ // one log line and an `available: false` block in the answer - never a failure of the tool.
2895
+ const discovery = createDiscoverySource({ logger: ctx.logger });
2896
+
2889
2897
  /** Text output: rendered as JSON, because every one of these tools answers with a structure. */
2890
2898
  const jsonOutput = {
2891
2899
  schema: { type: 'string' },
@@ -2901,12 +2909,22 @@ export async function apply(ctx, config = {}) {
2901
2909
  'List the machines the Rabbit currently holds leases for: machine_id, name, platform, ' +
2902
2910
  'capabilities, and when each was last seen. Read-only. Answers "who is online" without ' +
2903
2911
  'running anything; a machine that is listed may still refuse the next command for a reason ' +
2904
- 'the capability gate will report.',
2912
+ 'the capability gate will report. With include_discovered it also reports what mDNS sees on ' +
2913
+ 'the LAN in `discovery.candidates` - unverified hints, each marked with whether the relay ' +
2914
+ 'lists it, and never a dispatch target on their own.',
2905
2915
  parameters: {
2906
2916
  include_stale: {
2907
2917
  type: 'boolean',
2908
2918
  description: 'Include machines whose lease looks expired rather than only the live ones. Defaults to false.',
2909
2919
  },
2920
+ include_discovered: {
2921
+ type: 'boolean',
2922
+ default: false,
2923
+ description:
2924
+ 'Also scan the LAN over mDNS for about 1.5 s and report discovered candidates under ' +
2925
+ '`discovery`. Each candidate is marked unverified and carries `known_to_relay`; only a ' +
2926
+ 'candidate the relay also lists (by machine_id) may be dispatched to. Defaults to false.',
2927
+ },
2910
2928
  },
2911
2929
  output: jsonOutput,
2912
2930
  async execute(args, exec) {
@@ -2950,6 +2968,22 @@ export async function apply(ctx, config = {}) {
2950
2968
  const includeStale = args?.include_stale === true;
2951
2969
  const listed = fleet.records.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
2952
2970
 
2971
+ // The LAN hint layer. `include_discovered` runs one bounded scan; without it the block reports
2972
+ // the *last* scan's candidates against this call's roster, so a model that already paid for a
2973
+ // scan does not have to pay for it twice. Either way the candidates are described by
2974
+ // `mdns-discover.mjs`, which is where "discovered but not relay-listed is not a target" lives.
2975
+ const discoveryRequested = args?.include_discovered === true;
2976
+ const discoveryBlock = discoveryRequested
2977
+ ? await discovery.scan({
2978
+ signal: exec?.signal,
2979
+ relayRecords: fleet.records,
2980
+ localMachineId: identity.machine_id,
2981
+ })
2982
+ : discovery.describe({ relayRecords: fleet.records, localMachineId: identity.machine_id });
2983
+ // A cancel during the scan window is a cancel, not a partial answer: the signal is our caller's
2984
+ // withdrawal, and the same typed abort every other wait in this file produces is owed to them.
2985
+ if (discoveryRequested) throwIfAborted(exec?.signal);
2986
+
2953
2987
  return JSON.stringify(
2954
2988
  {
2955
2989
  ok: true,
@@ -2968,6 +3002,7 @@ export async function apply(ctx, config = {}) {
2968
3002
  state: machine?.state ?? 'online',
2969
3003
  })),
2970
3004
  excluded_stale: includeStale ? 0 : fleet.records.length - listed.length,
3005
+ discovery: discoveryBlock,
2971
3006
  note:
2972
3007
  'a listed device has paired and is being tracked; it can still refuse the next command ' +
2973
3008
  'at the capability gate, which w2m_wait reports per machine as `refused`',