@openscout/protocol 0.2.98 → 0.2.102

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,741 @@
1
+ import type { AdvertiseScope, MetadataMap, ScoutId } from "./common.js";
2
+
3
+ /**
4
+ * Machines — the physical (or virtual) boxes Scout knows about.
5
+ *
6
+ * A machine is NOT a node. A node is a Scout broker runtime; a machine is the
7
+ * computer it runs on. The distinction matters because most machines Scout can
8
+ * see are not running Scout at all: a tailnet peer, a Mac advertising itself on
9
+ * the LAN, a Linux box that only answers ARP. Modelling them as nodes would
10
+ * either invent fake nodes or make them invisible, and both were wrong.
11
+ *
12
+ * A machine record is assembled from **evidence**, never asserted. Four
13
+ * channels report independently and none of them is authoritative:
14
+ *
15
+ * | channel | tells us |
16
+ * | --------- | ----------------------------------------------- |
17
+ * | `scout` | a broker node lives here (id, mesh, urls) |
18
+ * | `tailnet` | reachable over Tailscale (stable peer id, IPs) |
19
+ * | `lan` | seen on this network (mDNS advert, ARP neighbor)|
20
+ * | `host` | terminal hosts present (herdr, tmux, zellij) |
21
+ *
22
+ * The join between them is `machineIdentityKeys` + `groupMachineEvidence`.
23
+ * Everything else in this module is a pure derivation over a group, so the
24
+ * whole model is testable without a network, a broker, or a database.
25
+ */
26
+
27
+ export const MACHINE_ID_PREFIX = "mach-";
28
+
29
+ export type MachineEvidenceKind = "scout" | "tailnet" | "lan" | "host";
30
+
31
+ export type MachinePlatform =
32
+ | "macos"
33
+ | "linux"
34
+ | "windows"
35
+ | "ios"
36
+ | "android"
37
+ | "unknown";
38
+
39
+ /**
40
+ * What a machine can do *for Scout*, as observed — never as configured. A
41
+ * capability is present because something answered, so an operator reading
42
+ * `herdr` on a row knows a herdr probe actually succeeded there.
43
+ */
44
+ export type MachineCapability =
45
+ | "scout-broker"
46
+ | "scout-web"
47
+ | "scout-pairing"
48
+ | "herdr"
49
+ | "tmux"
50
+ | "zellij"
51
+ | "ssh"
52
+ | "vnc"
53
+ | "smb"
54
+ | "http";
55
+
56
+ export type MachineRouteKind = "loopback" | "lan" | "tailnet" | "mesh" | "relay";
57
+
58
+ /** One way to reach a machine. Ordered by preference in `deriveMachineRoutes`. */
59
+ export interface MachineRoute {
60
+ kind: MachineRouteKind;
61
+ host: string;
62
+ port?: number;
63
+ url?: string;
64
+ lastSeenAt?: number;
65
+ }
66
+
67
+ export type MachineTerminalHost = "herdr" | "tmux" | "zellij";
68
+
69
+ export type LanDiscoveryMethod = "mdns" | "arp";
70
+
71
+ interface MachineEvidenceBase {
72
+ kind: MachineEvidenceKind;
73
+ observedAt: number;
74
+ }
75
+
76
+ /** A Scout broker node registered with, or discovered by, this broker. */
77
+ export interface ScoutMachineEvidence extends MachineEvidenceBase {
78
+ kind: "scout";
79
+ nodeId: ScoutId;
80
+ meshId?: ScoutId;
81
+ nodeName: string;
82
+ hostName?: string;
83
+ brokerUrl?: string;
84
+ webUrl?: string;
85
+ advertiseScope?: AdvertiseScope;
86
+ tailnetName?: string;
87
+ isSelf?: boolean;
88
+ }
89
+
90
+ /** A `tailscale status` peer (or self). Present whether or not it runs Scout. */
91
+ export interface TailnetMachineEvidence extends MachineEvidenceBase {
92
+ kind: "tailnet";
93
+ peerId: string;
94
+ hostName?: string;
95
+ dnsName?: string;
96
+ tailnetName?: string;
97
+ addresses: string[];
98
+ online: boolean;
99
+ os?: string;
100
+ tags?: string[];
101
+ isSelf?: boolean;
102
+ }
103
+
104
+ /** A local-network sighting: an mDNS service instance or an ARP neighbor. */
105
+ export interface LanMachineEvidence extends MachineEvidenceBase {
106
+ kind: "lan";
107
+ method: LanDiscoveryMethod;
108
+ hostName?: string;
109
+ instanceName?: string;
110
+ /** Full service type as browsed, e.g. `_openscout._tcp`. */
111
+ serviceType?: string;
112
+ addresses: string[];
113
+ port?: number;
114
+ macAddress?: string;
115
+ /** Best-effort OUI vendor. Null whenever the prefix is not in the table. */
116
+ vendor?: string;
117
+ interfaceName?: string;
118
+ txt?: MetadataMap;
119
+ }
120
+
121
+ /** A terminal host observed on a machine Scout can probe. */
122
+ export interface HostMachineEvidence extends MachineEvidenceBase {
123
+ kind: "host";
124
+ host: MachineTerminalHost;
125
+ /** Machine this was probed on — host probes are never self-identifying. */
126
+ hostName?: string;
127
+ nodeId?: ScoutId;
128
+ sessionCount: number;
129
+ runningSessionCount?: number;
130
+ version?: string;
131
+ }
132
+
133
+ export type MachineEvidence =
134
+ | ScoutMachineEvidence
135
+ | TailnetMachineEvidence
136
+ | LanMachineEvidence
137
+ | HostMachineEvidence;
138
+
139
+ /**
140
+ * Identity keys, split by how long they stay true.
141
+ *
142
+ * `durable` keys name the machine itself — a Tailscale node id, a MAC, a Scout
143
+ * node id, a hostname. They are persisted with the record and re-join evidence
144
+ * across passes, days apart.
145
+ *
146
+ * `transient` keys are only true right now. A LAN IP is the important one: it
147
+ * is what joins an ARP row to an mDNS advert *inside one scan*, and it is
148
+ * exactly what DHCP hands to a different machine next week. Persisting it
149
+ * would silently fuse two machines, so it never leaves the pass that saw it.
150
+ */
151
+ export interface MachineIdentityKeys {
152
+ durable: string[];
153
+ transient: string[];
154
+ }
155
+
156
+ const EMPTY_KEYS: MachineIdentityKeys = { durable: [], transient: [] };
157
+
158
+ /** mDNS service types Scout browses, and what each one proves. */
159
+ export const MACHINE_SERVICE_CAPABILITIES: Readonly<Record<string, MachineCapability>> = {
160
+ "_openscout._tcp": "scout-broker",
161
+ "_oscout-pair._tcp": "scout-pairing",
162
+ "_ssh._tcp": "ssh",
163
+ "_sftp-ssh._tcp": "ssh",
164
+ "_rfb._tcp": "vnc",
165
+ "_smb._tcp": "smb",
166
+ "_http._tcp": "http",
167
+ };
168
+
169
+ /** Browsed for inventory. `_workstation._tcp` proves nothing but presence. */
170
+ export const MACHINE_LAN_SERVICE_TYPES: readonly string[] = [
171
+ "_openscout._tcp",
172
+ "_oscout-pair._tcp",
173
+ "_ssh._tcp",
174
+ "_workstation._tcp",
175
+ "_rfb._tcp",
176
+ "_smb._tcp",
177
+ ];
178
+
179
+ /* ── Normalization ── */
180
+
181
+ /**
182
+ * Reduce a host label to the machine's short name: `mini.local.` → `mini`,
183
+ * `mini.tail1234.ts.net` → `mini`.
184
+ *
185
+ * Bonjour renames a colliding instance to `mini (2)` or `mini-2.local`, and
186
+ * both must fold back to `mini` or one machine becomes two. The numeric form
187
+ * is only stripped for a `.local` name — a machine genuinely called `node-1`
188
+ * must keep its digit, which is the same rule
189
+ * `stripBonjourCollisionSuffix` follows in the runtime.
190
+ */
191
+ /**
192
+ * Host names that every machine can answer to and therefore identify none of
193
+ * them. iOS devices report `localhost` as their Tailscale host name, so
194
+ * treating this as identity fuses an iPhone and an iPad into one record.
195
+ */
196
+ const NON_IDENTIFYING_HOST_NAMES = new Set(["localhost", "ip6-localhost", "unknown", "android"]);
197
+
198
+ export function normalizeMachineHostName(value: string | null | undefined): string {
199
+ const raw = (value ?? "").trim().toLowerCase();
200
+ if (!raw) return "";
201
+ const withoutTrailingDot = raw.replace(/\.+$/, "");
202
+ const isMdnsName = /\.local$/.test(withoutTrailingDot);
203
+ const firstLabel = (withoutTrailingDot.split(".")[0] ?? "").replace(/\s*\(\d+\)$/, "").trim();
204
+ // A machine that has re-registered hundreds of times carries a suffix to
205
+ // match: `Arts-Mac-mini-769.local` and `Arts-Mac-mini-612.local` are one Mac,
206
+ // and capping at two digits left 25 of them as 25 machines.
207
+ const name = isMdnsName ? firstLabel.replace(/-\d{1,4}$/, "") : firstLabel;
208
+ return NON_IDENTIFYING_HOST_NAMES.has(name) ? "" : name;
209
+ }
210
+
211
+ /**
212
+ * Lowercase colon-form MAC. Returns "" for entries that identify nothing —
213
+ * broadcast, all-zero, or an incomplete ARP row.
214
+ *
215
+ * BSD `arp` drops leading zeros (`3c:22:fb:1:2:3`), so octets are padded
216
+ * rather than the string being scrubbed of separators: scrubbing turns that
217
+ * nine-character form into a rejected MAC instead of a valid one.
218
+ */
219
+ export function normalizeMacAddress(value: string | null | undefined): string {
220
+ const raw = (value ?? "").trim().toLowerCase();
221
+ if (!raw) return "";
222
+
223
+ const groups = raw.split(/[:-]/);
224
+ const octets = groups.length === 6
225
+ ? groups.map((group) => (/^[0-9a-f]{1,2}$/.test(group) ? group.padStart(2, "0") : ""))
226
+ : (raw.replace(/[^0-9a-f]/g, "").match(/.{2}/g) ?? []);
227
+
228
+ if (octets.length !== 6 || octets.some((octet) => !/^[0-9a-f]{2}$/.test(octet))) return "";
229
+ const joined = octets.join(":");
230
+ if (joined === "00:00:00:00:00:00") return "";
231
+ // The low bit of the first octet is the I/G bit: when it is set the address
232
+ // is a group (multicast or broadcast) destination, never a station address.
233
+ // Real ARP tables are full of these — `01:00:5e:00:00:fb` is mDNS, not a
234
+ // machine — and treating one as identity would mint a phantom neighbour on
235
+ // every network Scout ever sees.
236
+ if ((Number.parseInt(octets[0] ?? "0", 16) & 0x01) === 1) return "";
237
+ return joined;
238
+ }
239
+
240
+ export function normalizeMachineAddress(value: string | null | undefined): string {
241
+ const raw = (value ?? "").trim().toLowerCase();
242
+ if (!raw) return "";
243
+ return raw.replace(/^\[/, "").replace(/\]$/, "").replace(/%.*$/, "");
244
+ }
245
+
246
+ export function isLoopbackAddress(value: string): boolean {
247
+ const address = normalizeMachineAddress(value);
248
+ return address === "127.0.0.1" || address === "::1" || address === "localhost";
249
+ }
250
+
251
+ /** Tailscale's CGNAT range (100.64.0.0/10) and its IPv6 ULA prefix. */
252
+ export function isTailnetAddress(value: string): boolean {
253
+ const address = normalizeMachineAddress(value);
254
+ if (address.startsWith("fd7a:115c:a1e0")) return true;
255
+ const octets = address.split(".");
256
+ if (octets.length !== 4) return false;
257
+ const first = Number.parseInt(octets[0] ?? "", 10);
258
+ const second = Number.parseInt(octets[1] ?? "", 10);
259
+ return first === 100 && second >= 64 && second <= 127;
260
+ }
261
+
262
+ /* ── Identity ── */
263
+
264
+ function pushUnique<T>(target: T[], value: T | null | undefined): void {
265
+ if (value === null || value === undefined || value === "") return;
266
+ if (!target.includes(value)) target.push(value);
267
+ }
268
+
269
+ const pushKey = pushUnique<string>;
270
+
271
+ /**
272
+ * The keys one piece of evidence claims. Two pieces of evidence describe the
273
+ * same machine when they share any key — see `groupMachineEvidence`.
274
+ */
275
+ export function machineIdentityKeys(evidence: MachineEvidence): MachineIdentityKeys {
276
+ const durable: string[] = [];
277
+ const transient: string[] = [];
278
+
279
+ switch (evidence.kind) {
280
+ case "scout": {
281
+ pushKey(durable, `node:${evidence.nodeId}`);
282
+ const host = normalizeMachineHostName(evidence.hostName)
283
+ || normalizeMachineHostName(evidence.nodeName);
284
+ if (host) pushKey(durable, `host:${host}`);
285
+ return { durable, transient };
286
+ }
287
+ case "tailnet": {
288
+ pushKey(durable, `tailnet:${evidence.peerId}`);
289
+ const host = normalizeMachineHostName(evidence.hostName)
290
+ || normalizeMachineHostName(evidence.dnsName);
291
+ if (host) pushKey(durable, `host:${host}`);
292
+ // A tailnet address is assigned by the coordination server and stays with
293
+ // the node, so unlike a LAN address it is safe to keep.
294
+ for (const address of evidence.addresses) {
295
+ const normalized = normalizeMachineAddress(address);
296
+ if (!normalized) continue;
297
+ if (isTailnetAddress(normalized)) pushKey(durable, `ip:${normalized}`);
298
+ else pushKey(transient, `ip:${normalized}`);
299
+ }
300
+ return { durable, transient };
301
+ }
302
+ case "lan": {
303
+ const mac = normalizeMacAddress(evidence.macAddress);
304
+ if (mac) pushKey(durable, `mac:${mac}`);
305
+ const host = normalizeMachineHostName(evidence.hostName)
306
+ || normalizeMachineHostName(evidence.instanceName);
307
+ if (host) pushKey(durable, `host:${host}`);
308
+ for (const address of evidence.addresses) {
309
+ const normalized = normalizeMachineAddress(address);
310
+ if (!normalized || isLoopbackAddress(normalized)) continue;
311
+ if (isTailnetAddress(normalized)) pushKey(durable, `ip:${normalized}`);
312
+ else pushKey(transient, `ip:${normalized}`);
313
+ }
314
+ return { durable, transient };
315
+ }
316
+ case "host": {
317
+ if (evidence.nodeId) pushKey(durable, `node:${evidence.nodeId}`);
318
+ const host = normalizeMachineHostName(evidence.hostName);
319
+ if (host) pushKey(durable, `host:${host}`);
320
+ return { durable, transient };
321
+ }
322
+ default:
323
+ return EMPTY_KEYS;
324
+ }
325
+ }
326
+
327
+ export interface MachineEvidenceGroup {
328
+ /** Durable keys only — what gets persisted so the next pass re-joins here. */
329
+ identityKeys: string[];
330
+ evidence: MachineEvidence[];
331
+ /**
332
+ * Indices of every seed this group attached to, ascending. More than one
333
+ * means fresh evidence proved two records were always the same machine —
334
+ * the first is the survivor, the rest are absorbed by the caller.
335
+ */
336
+ seedIndices: number[];
337
+ }
338
+
339
+ /**
340
+ * Union-find over identity keys. Evidence lands in the same group when it
341
+ * shares any key, directly or through a chain — an ARP row and an mDNS advert
342
+ * share an IP; the advert and a tailnet peer share a hostname; so all three
343
+ * describe one machine even though ARP and Tailscale share nothing at all.
344
+ *
345
+ * `seeds` are the durable key sets of machines already on record. They enter
346
+ * the union as members, which is what lets a machine seen only by ARP today
347
+ * and only by Tailscale tomorrow stay one record: the seed bridges the passes.
348
+ */
349
+ export function groupMachineEvidence(
350
+ evidence: readonly MachineEvidence[],
351
+ seeds: readonly (readonly string[])[] = [],
352
+ ): MachineEvidenceGroup[] {
353
+ const parent: number[] = [];
354
+ const find = (index: number): number => {
355
+ let root = index;
356
+ while (parent[root] !== root) root = parent[root]!;
357
+ let cursor = index;
358
+ while (parent[cursor] !== root) {
359
+ const next = parent[cursor]!;
360
+ parent[cursor] = root;
361
+ cursor = next;
362
+ }
363
+ return root;
364
+ };
365
+ const union = (a: number, b: number): void => {
366
+ const rootA = find(a);
367
+ const rootB = find(b);
368
+ if (rootA !== rootB) parent[rootB] = rootA;
369
+ };
370
+
371
+ // Members: seeds first (so a seed's slot index is its own), then evidence.
372
+ const memberKeys: MachineIdentityKeys[] = [];
373
+ for (const seed of seeds) {
374
+ parent.push(parent.length);
375
+ memberKeys.push({ durable: [...seed], transient: [] });
376
+ }
377
+ const evidenceOffset = memberKeys.length;
378
+ for (const item of evidence) {
379
+ parent.push(parent.length);
380
+ memberKeys.push(machineIdentityKeys(item));
381
+ }
382
+
383
+ const owners = new Map<string, number>();
384
+ memberKeys.forEach((keys, index) => {
385
+ for (const key of [...keys.durable, ...keys.transient]) {
386
+ const existing = owners.get(key);
387
+ if (existing === undefined) owners.set(key, index);
388
+ else union(existing, index);
389
+ }
390
+ });
391
+
392
+ const groups = new Map<number, MachineEvidenceGroup>();
393
+ const groupOrder: number[] = [];
394
+ const ensureGroup = (root: number): MachineEvidenceGroup => {
395
+ const existing = groups.get(root);
396
+ if (existing) return existing;
397
+ const created: MachineEvidenceGroup = { identityKeys: [], evidence: [], seedIndices: [] };
398
+ groups.set(root, created);
399
+ groupOrder.push(root);
400
+ return created;
401
+ };
402
+
403
+ memberKeys.forEach((keys, index) => {
404
+ const group = ensureGroup(find(index));
405
+ for (const key of keys.durable) pushKey(group.identityKeys, key);
406
+ if (index < evidenceOffset) group.seedIndices.push(index);
407
+ else group.evidence.push(evidence[index - evidenceOffset]!);
408
+ });
409
+
410
+ return groupOrder
411
+ .map((root) => groups.get(root)!)
412
+ // A seed with no fresh evidence is a machine nobody saw this pass. It stays
413
+ // on record (the caller keeps it), but it is not a result of this scan.
414
+ .filter((group) => group.evidence.length > 0)
415
+ .map((group) => ({
416
+ ...group,
417
+ identityKeys: [...group.identityKeys].sort(),
418
+ // Ascending, so the lowest-indexed record is the stable survivor of a merge.
419
+ seedIndices: [...group.seedIndices].sort((a, b) => a - b),
420
+ }));
421
+ }
422
+
423
+ /**
424
+ * Deterministic id from a group's durable keys, so two brokers observing the
425
+ * same machine mint the same id and a restart never renumbers the fleet.
426
+ * FNV-1a ×2, matching `stableChannelId`.
427
+ */
428
+ export function stableMachineId(identityKeys: readonly string[]): ScoutId {
429
+ const normalized = [...identityKeys].map((key) => key.trim().toLowerCase()).sort().join("|");
430
+ const hash = (seed: bigint): string => {
431
+ let value = seed;
432
+ for (let index = 0; index < normalized.length; index += 1) {
433
+ value ^= BigInt(normalized.charCodeAt(index));
434
+ value = BigInt.asUintN(64, value * 0x100000001b3n);
435
+ }
436
+ return value.toString(16).padStart(16, "0");
437
+ };
438
+ return `${MACHINE_ID_PREFIX}${hash(0xcbf29ce484222325n)}${hash(0x84222325cbf29ce4n)}`;
439
+ }
440
+
441
+ /* ── Derivations over a group ── */
442
+
443
+ function evidenceOf<K extends MachineEvidence["kind"]>(
444
+ evidence: readonly MachineEvidence[],
445
+ kind: K,
446
+ ): Extract<MachineEvidence, { kind: K }>[] {
447
+ return evidence.filter((item): item is Extract<MachineEvidence, { kind: K }> => item.kind === kind);
448
+ }
449
+
450
+ /**
451
+ * The name to show. Scout's own name for the node wins because the operator
452
+ * chose it; then the tailnet hostname, then whatever the LAN advertised. A
453
+ * machine known only by MAC gets its MAC, never a fabricated label.
454
+ */
455
+ export function deriveMachineName(evidence: readonly MachineEvidence[]): string {
456
+ for (const scout of evidenceOf(evidence, "scout")) {
457
+ // A node name is a host name — `Arts-Mac-mini-534.local`, suffix and all —
458
+ // so it goes through the same normalizer rather than straight to the UI.
459
+ const name = normalizeMachineHostName(scout.nodeName) || normalizeMachineHostName(scout.hostName);
460
+ if (name) return name;
461
+ }
462
+
463
+ const tailnet = evidenceOf(evidence, "tailnet")
464
+ .sort((a, b) => Number(b.online) - Number(a.online))[0];
465
+ const tailnetName = normalizeMachineHostName(tailnet?.hostName)
466
+ || normalizeMachineHostName(tailnet?.dnsName);
467
+ if (tailnetName) return tailnetName;
468
+
469
+ for (const lan of evidenceOf(evidence, "lan")) {
470
+ const name = normalizeMachineHostName(lan.hostName) || normalizeMachineHostName(lan.instanceName);
471
+ if (name) return name;
472
+ }
473
+
474
+ // A terminal host names the machine it was probed on. Weaker than a node or
475
+ // a tailnet peer, but far better than falling through to a bare MAC for a
476
+ // box we are literally running sessions on.
477
+ for (const host of evidenceOf(evidence, "host")) {
478
+ const name = normalizeMachineHostName(host.hostName);
479
+ if (name) return name;
480
+ }
481
+
482
+ const mac = evidenceOf(evidence, "lan")
483
+ .map((lan) => normalizeMacAddress(lan.macAddress))
484
+ .find(Boolean);
485
+ if (mac) return mac;
486
+
487
+ const address = evidenceOf(evidence, "lan")
488
+ .flatMap((lan) => lan.addresses.map(normalizeMachineAddress))
489
+ .find((value) => value && !isLoopbackAddress(value));
490
+ return address || "unknown";
491
+ }
492
+
493
+ const TAILSCALE_OS_PLATFORMS: Readonly<Record<string, MachinePlatform>> = {
494
+ macos: "macos",
495
+ macOS: "macos",
496
+ darwin: "macos",
497
+ linux: "linux",
498
+ windows: "windows",
499
+ ios: "ios",
500
+ iOS: "ios",
501
+ android: "android",
502
+ };
503
+
504
+ export function deriveMachinePlatform(evidence: readonly MachineEvidence[]): MachinePlatform {
505
+ for (const tailnet of evidenceOf(evidence, "tailnet")) {
506
+ const os = (tailnet.os ?? "").trim();
507
+ const platform = TAILSCALE_OS_PLATFORMS[os] ?? TAILSCALE_OS_PLATFORMS[os.toLowerCase()];
508
+ if (platform) return platform;
509
+ }
510
+ // Only Apple platforms publish `_rfb._tcp` alongside `_ssh._tcp` by default
511
+ // on a stock install, but that is a guess, not proof — keep it to the
512
+ // Bonjour `model` TXT key, which Apple devices set and others do not.
513
+ for (const lan of evidenceOf(evidence, "lan")) {
514
+ const model = String(lan.txt?.model ?? "").trim();
515
+ if (/^(mac|imac|macbook|macmini|macpro|macstudio)/i.test(model)) return "macos";
516
+ }
517
+ return "unknown";
518
+ }
519
+
520
+ export function deriveMachineCapabilities(
521
+ evidence: readonly MachineEvidence[],
522
+ ): MachineCapability[] {
523
+ const capabilities: MachineCapability[] = [];
524
+
525
+ for (const scout of evidenceOf(evidence, "scout")) {
526
+ if (scout.brokerUrl) pushUnique(capabilities, "scout-broker");
527
+ if (scout.webUrl) pushUnique(capabilities, "scout-web");
528
+ }
529
+ for (const lan of evidenceOf(evidence, "lan")) {
530
+ pushUnique(capabilities, lan.serviceType ? MACHINE_SERVICE_CAPABILITIES[lan.serviceType] : null);
531
+ }
532
+ for (const host of evidenceOf(evidence, "host")) {
533
+ pushUnique(capabilities, host.host);
534
+ }
535
+
536
+ return capabilities.sort();
537
+ }
538
+
539
+ /**
540
+ * Routes, best first: loopback (it's this machine), then LAN (no hop), then
541
+ * tailnet, then whatever the mesh advertised. This is the order a caller
542
+ * should dial, and it mirrors the LAN-first preference iOS already uses.
543
+ */
544
+ /**
545
+ * Every way we know to reach this machine, best first. `isSelf` decides whether
546
+ * loopback belongs at all: a peer advertising `http://127.0.0.1:43120` is
547
+ * describing its own loopback, which is not a route to it from here.
548
+ */
549
+ export function deriveMachineRoutes(
550
+ evidence: readonly MachineEvidence[],
551
+ isSelf = false,
552
+ ): MachineRoute[] {
553
+ const routes: MachineRoute[] = [];
554
+ const seen = new Set<string>();
555
+ const add = (route: MachineRoute): void => {
556
+ const key = `${route.kind}|${route.host}|${route.port ?? ""}`;
557
+ if (seen.has(key)) return;
558
+ seen.add(key);
559
+ routes.push(route);
560
+ };
561
+
562
+ for (const scout of evidenceOf(evidence, "scout")) {
563
+ for (const url of [scout.brokerUrl, scout.webUrl]) {
564
+ if (!url) continue;
565
+ try {
566
+ const parsed = new URL(url);
567
+ const host = normalizeMachineAddress(parsed.hostname);
568
+ const port = parsed.port ? Number.parseInt(parsed.port, 10) : undefined;
569
+ const kind: MachineRouteKind = isLoopbackAddress(host)
570
+ ? "loopback"
571
+ : isTailnetAddress(host)
572
+ ? "tailnet"
573
+ : "mesh";
574
+ add({ kind, host, ...(port ? { port } : {}), url, lastSeenAt: scout.observedAt });
575
+ } catch {
576
+ // A malformed advertised URL is the peer's problem, not a reason to
577
+ // drop every other route this machine has.
578
+ }
579
+ }
580
+ }
581
+
582
+ for (const lan of evidenceOf(evidence, "lan")) {
583
+ for (const address of lan.addresses) {
584
+ const host = normalizeMachineAddress(address);
585
+ if (!host || isLoopbackAddress(host)) continue;
586
+ add({
587
+ kind: isTailnetAddress(host) ? "tailnet" : "lan",
588
+ host,
589
+ ...(lan.port ? { port: lan.port } : {}),
590
+ lastSeenAt: lan.observedAt,
591
+ });
592
+ }
593
+ }
594
+
595
+ for (const tailnet of evidenceOf(evidence, "tailnet")) {
596
+ for (const address of tailnet.addresses) {
597
+ const host = normalizeMachineAddress(address);
598
+ if (!host) continue;
599
+ add({ kind: "tailnet", host, lastSeenAt: tailnet.observedAt });
600
+ }
601
+ const dnsName = (tailnet.dnsName ?? "").trim().replace(/\.+$/, "");
602
+ if (dnsName) add({ kind: "tailnet", host: dnsName, lastSeenAt: tailnet.observedAt });
603
+ }
604
+
605
+ const order: Record<MachineRouteKind, number> = {
606
+ lan: 0,
607
+ tailnet: 1,
608
+ mesh: 2,
609
+ relay: 3,
610
+ // Last, not first: `127.0.0.1` only reaches the machine you are already on.
611
+ // A dozen peers each advertising `http://127.0.0.1:43120` made loopback the
612
+ // headline address for machines it cannot reach at all.
613
+ loopback: 4,
614
+ };
615
+ return routes
616
+ .filter((route) => isSelf || route.kind !== "loopback")
617
+ .sort((a, b) => order[a.kind] - order[b.kind]);
618
+ }
619
+
620
+ /* ── The record ── */
621
+
622
+ export type MachinePresence = "online" | "recent" | "offline";
623
+
624
+ /** Anything seen inside this window counts as present. */
625
+ export const MACHINE_ONLINE_WINDOW_MS = 2 * 60_000;
626
+ export const MACHINE_RECENT_WINDOW_MS = 60 * 60_000;
627
+
628
+ export interface MachineRecord {
629
+ id: ScoutId;
630
+ /** Operator-set name. Null means "use `name`". */
631
+ displayName: string | null;
632
+ /** Derived from the strongest evidence — recomputed every pass. */
633
+ name: string;
634
+ platform: MachinePlatform;
635
+ /** Durable keys, sorted. The join key set for the next pass. */
636
+ identityKeys: string[];
637
+ isSelf: boolean;
638
+ scoutNodeId?: ScoutId;
639
+ meshId?: ScoutId;
640
+ tailnetId?: string;
641
+ tailnetName?: string;
642
+ hostNames: string[];
643
+ addresses: string[];
644
+ macAddresses: string[];
645
+ capabilities: MachineCapability[];
646
+ routes: MachineRoute[];
647
+ evidence: MachineEvidence[];
648
+ /** Operator marked it worth keeping even when nothing has seen it in weeks. */
649
+ pinned: boolean;
650
+ notes?: string;
651
+ firstSeenAt: number;
652
+ lastSeenAt: number;
653
+ metadata?: MetadataMap;
654
+ }
655
+
656
+ export function machineLabel(machine: Pick<MachineRecord, "displayName" | "name">): string {
657
+ return machine.displayName?.trim() || machine.name;
658
+ }
659
+
660
+ export function machinePresence(
661
+ machine: Pick<MachineRecord, "lastSeenAt">,
662
+ now: number = Date.now(),
663
+ ): MachinePresence {
664
+ const age = now - machine.lastSeenAt;
665
+ if (age <= MACHINE_ONLINE_WINDOW_MS) return "online";
666
+ if (age <= MACHINE_RECENT_WINDOW_MS) return "recent";
667
+ return "offline";
668
+ }
669
+
670
+ /**
671
+ * Build (or rebuild) a record from a group. `previous` carries the parts an
672
+ * operator owns — id, chosen name, notes, pin, first-seen — which no amount of
673
+ * fresh evidence may overwrite.
674
+ */
675
+ export function buildMachineRecord(
676
+ group: MachineEvidenceGroup,
677
+ previous?: Partial<MachineRecord> | null,
678
+ ): MachineRecord {
679
+ const evidence = [...group.evidence].sort((a, b) => a.observedAt - b.observedAt);
680
+ const observedAt = evidence.reduce((latest, item) => Math.max(latest, item.observedAt), 0);
681
+
682
+ const scout = evidenceOf(evidence, "scout")[0];
683
+ const tailnet = evidenceOf(evidence, "tailnet")
684
+ .sort((a, b) => Number(b.online) - Number(a.online))[0];
685
+
686
+ const hostNames: string[] = [];
687
+ const addresses: string[] = [];
688
+ const macAddresses: string[] = [];
689
+ for (const key of group.identityKeys) {
690
+ const [prefix, ...rest] = key.split(":");
691
+ const value = rest.join(":");
692
+ if (prefix === "host") pushKey(hostNames, value);
693
+ else if (prefix === "ip") pushKey(addresses, value);
694
+ else if (prefix === "mac") pushKey(macAddresses, value);
695
+ }
696
+ for (const item of evidence) {
697
+ if (item.kind === "lan" || item.kind === "tailnet") {
698
+ for (const address of item.addresses) {
699
+ const normalized = normalizeMachineAddress(address);
700
+ if (normalized && !isLoopbackAddress(normalized)) pushKey(addresses, normalized);
701
+ }
702
+ }
703
+ }
704
+
705
+ const isSelf = evidence.some((item) => (
706
+ (item.kind === "scout" || item.kind === "tailnet") && item.isSelf === true
707
+ ));
708
+
709
+ return {
710
+ id: previous?.id ?? stableMachineId(group.identityKeys),
711
+ displayName: previous?.displayName ?? null,
712
+ name: deriveMachineName(evidence),
713
+ platform: deriveMachinePlatform(evidence),
714
+ identityKeys: group.identityKeys,
715
+ isSelf,
716
+ ...(scout?.nodeId ? { scoutNodeId: scout.nodeId } : {}),
717
+ ...(scout?.meshId ? { meshId: scout.meshId } : {}),
718
+ ...(tailnet?.peerId ? { tailnetId: tailnet.peerId } : {}),
719
+ ...(tailnet?.tailnetName ?? scout?.tailnetName
720
+ ? { tailnetName: tailnet?.tailnetName ?? scout?.tailnetName }
721
+ : {}),
722
+ hostNames,
723
+ addresses,
724
+ macAddresses,
725
+ capabilities: deriveMachineCapabilities(evidence),
726
+ routes: deriveMachineRoutes(evidence, isSelf),
727
+ evidence,
728
+ pinned: previous?.pinned ?? false,
729
+ ...(previous?.notes ? { notes: previous.notes } : {}),
730
+ firstSeenAt: previous?.firstSeenAt ?? observedAt,
731
+ lastSeenAt: Math.max(observedAt, previous?.lastSeenAt ?? 0),
732
+ ...(previous?.metadata ? { metadata: previous.metadata } : {}),
733
+ };
734
+ }
735
+
736
+ /** Sort for display: this machine, then present ones, then by name. */
737
+ export function compareMachines(a: MachineRecord, b: MachineRecord): number {
738
+ if (a.isSelf !== b.isSelf) return a.isSelf ? -1 : 1;
739
+ if (a.lastSeenAt !== b.lastSeenAt) return b.lastSeenAt - a.lastSeenAt;
740
+ return machineLabel(a).localeCompare(machineLabel(b));
741
+ }