@twinsearth/w2m-dsh-plugin 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/README.md +3 -1
- package/cordis.patch.yml +27 -4
- package/lib/client.js +145 -18
- package/lib/index.js +16 -11
- package/lib/tools.js +9 -3
- package/package.json +1 -1
- package/src/agent/mdns-wire.mjs +1062 -0
- package/src/agent/mdns.mjs +1311 -0
- package/src/plugin/fleet-target.mjs +84 -20
- package/src/plugin/mdns-discover.mjs +577 -0
- package/src/plugin/tools.mjs +43 -5
|
@@ -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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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;
|
|
@@ -2872,6 +2875,12 @@ export async function apply(ctx, config = {}) {
|
|
|
2872
2875
|
// host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence —
|
|
2873
2876
|
// which is the honest place for it, since a host that has no Connection at all is simply not a web
|
|
2874
2877
|
// composition and does not need a warning on every headless run.
|
|
2878
|
+
//
|
|
2879
|
+
// Nothing here reads the service: `registerTargetRoutes` declares the optional dependency with
|
|
2880
|
+
// `ctx.inject` and attaches from that callback (the comment there records why a plain
|
|
2881
|
+
// `ctx.connection` read fails the whole entry, and why the read cannot be synchronous anyway). The
|
|
2882
|
+
// one mismatch worth a log line — a Connection that carries no fetch registry — is logged where it
|
|
2883
|
+
// is observed, inside that callback, so this call returns a status object that stays live.
|
|
2875
2884
|
const targetRoutes = registerTargetRoutes(ctx, {
|
|
2876
2885
|
cfg: () => cfg,
|
|
2877
2886
|
store: defaultSelectionStore,
|
|
@@ -2879,9 +2888,11 @@ export async function apply(ctx, config = {}) {
|
|
|
2879
2888
|
request,
|
|
2880
2889
|
resolveToken,
|
|
2881
2890
|
});
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
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 });
|
|
2885
2896
|
|
|
2886
2897
|
/** Text output: rendered as JSON, because every one of these tools answers with a structure. */
|
|
2887
2898
|
const jsonOutput = {
|
|
@@ -2898,12 +2909,22 @@ export async function apply(ctx, config = {}) {
|
|
|
2898
2909
|
'List the machines the Rabbit currently holds leases for: machine_id, name, platform, ' +
|
|
2899
2910
|
'capabilities, and when each was last seen. Read-only. Answers "who is online" without ' +
|
|
2900
2911
|
'running anything; a machine that is listed may still refuse the next command for a reason ' +
|
|
2901
|
-
'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.',
|
|
2902
2915
|
parameters: {
|
|
2903
2916
|
include_stale: {
|
|
2904
2917
|
type: 'boolean',
|
|
2905
2918
|
description: 'Include machines whose lease looks expired rather than only the live ones. Defaults to false.',
|
|
2906
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
|
+
},
|
|
2907
2928
|
},
|
|
2908
2929
|
output: jsonOutput,
|
|
2909
2930
|
async execute(args, exec) {
|
|
@@ -2947,6 +2968,22 @@ export async function apply(ctx, config = {}) {
|
|
|
2947
2968
|
const includeStale = args?.include_stale === true;
|
|
2948
2969
|
const listed = fleet.records.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
|
|
2949
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
|
+
|
|
2950
2987
|
return JSON.stringify(
|
|
2951
2988
|
{
|
|
2952
2989
|
ok: true,
|
|
@@ -2965,6 +3002,7 @@ export async function apply(ctx, config = {}) {
|
|
|
2965
3002
|
state: machine?.state ?? 'online',
|
|
2966
3003
|
})),
|
|
2967
3004
|
excluded_stale: includeStale ? 0 : fleet.records.length - listed.length,
|
|
3005
|
+
discovery: discoveryBlock,
|
|
2968
3006
|
note:
|
|
2969
3007
|
'a listed device has paired and is being tracked; it can still refuse the next command ' +
|
|
2970
3008
|
'at the capability gate, which w2m_wait reports per machine as `refused`',
|