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