ccdeck 3.24.1 → 3.26.0

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.
@@ -23,6 +23,7 @@
23
23
  // address.
24
24
  import dgram from "node:dgram";
25
25
  import { networkInterfaces } from "node:os";
26
+ import { looksLikeTunnel } from "./route-via.mjs";
26
27
 
27
28
  /** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
28
29
  /**
@@ -75,18 +76,43 @@ export function directedBroadcast(address, netmask) {
75
76
  * IPv6 broadcast address.
76
77
  */
77
78
  export function broadcastTargets(ifaces) {
78
- const out = ["255.255.255.255"];
79
- for (const list of Object.values(ifaces ?? {})) {
79
+ return broadcastPlan(ifaces).map(p => p.to);
80
+ }
81
+
82
+ /**
83
+ * The same targets, each with the interface whose subnet it is — null for the
84
+ * limited broadcast, which is nobody's. What lets a beacon be held back when
85
+ * the machine would send it out by some OTHER interface: see leavesByTunnel.
86
+ */
87
+ export function broadcastPlan(ifaces) {
88
+ const out = [{ to: "255.255.255.255", iface: null }];
89
+ for (const [name, list] of Object.entries(ifaces ?? {})) {
80
90
  for (const ni of list ?? []) {
81
91
  if (!ni || ni.internal) continue;
82
92
  // Node 18 reports `family` as the string "IPv4"; older shapes used 4.
83
93
  if (ni.family !== "IPv4" && ni.family !== 4) continue;
84
94
  const to = directedBroadcast(ni.address, ni.netmask);
85
- if (to && !out.includes(to)) out.push(to);
95
+ if (to && !out.some(p => p.to === to)) out.push({ to, iface: name });
86
96
  }
87
97
  }
88
98
  return out;
89
99
  }
100
+
101
+ /**
102
+ * Would this broadcast leave the local network?
103
+ *
104
+ * A directed broadcast belongs to the interface whose subnet it is, so the
105
+ * machine sending it out by any other one means something rerouted the local
106
+ * network — a VPN, or a Tailscale exit node without local network access —
107
+ * and the beacon would come out on the far end of the tunnel. The limited
108
+ * broadcast belongs to none, so it is held back only when it would go into
109
+ * something that is plainly a tunnel. Unknown answers nothing: send.
110
+ */
111
+ export function leavesByTunnel(target, via, isTunnel) {
112
+ if (!via) return false;
113
+ if (target.iface) return via !== target.iface;
114
+ return isTunnel(via);
115
+ }
90
116
  import net from "node:net";
91
117
  import { randomBytes } from "node:crypto";
92
118
  import {
@@ -201,6 +227,30 @@ export function createBeacon({
201
227
  // restarting the deck, and a list captured at start would announce to the
202
228
  // addresses it had at breakfast.
203
229
  ifaces = () => networkInterfaces(),
230
+ /**
231
+ * Addresses to send the beacon to one by one, beside the broadcast — the
232
+ * owner's machines on Tailscale, whose tunnel carries no broadcast at all.
233
+ * Read per announce like `ifaces`, because the tailnet list is re-read while
234
+ * the deck runs and a machine that just came online belongs in the next one.
235
+ */
236
+ unicast = () => [],
237
+ /**
238
+ * Which way a packet from this address came: "lan", "tailscale", or null for
239
+ * one to ignore entirely. Null is what a tailnet packet gets while this
240
+ * deck's Tailscale switch is off — the sockets hear it either way, because
241
+ * they bind every interface, and off has to mean the deck does not act on it.
242
+ */
243
+ routeFor = () => "lan",
244
+ /** How long to wait before trying the discovery port again while another
245
+ * program holds it, and who to tell when hearing stops or comes back. */
246
+ rebindMs = 30_000,
247
+ onHearing,
248
+ /**
249
+ * Where the machine would send each broadcast — see createRouteCheck in
250
+ * route-via.mjs. Absent, every broadcast goes, as before; the suite's
251
+ * beacons have none.
252
+ */
253
+ routes = null,
204
254
  } = {}) {
205
255
  // Randomised per process. Two beacons from one fingerprint with different
206
256
  // instance ids mean the deck restarted between them, which is the signal to
@@ -213,7 +263,34 @@ export function createBeacon({
213
263
  const host = hostId();
214
264
  const peers = new Map();
215
265
  let sock = null;
266
+ /**
267
+ * The socket beacons LEAVE by, on a port of its own. See start.
268
+ *
269
+ * Null until it is bound, and whenever it could not be — the listening
270
+ * socket then sends as it always did, because a beacon from the wrong port is
271
+ * still a beacon and none at all is a deck nobody finds.
272
+ */
273
+ let out = null;
216
274
  let timer = null;
275
+ /**
276
+ * Whether this deck can HEAR — whether it holds the discovery port.
277
+ *
278
+ * SEPARATE FROM RUNNING, because a deck that cannot bind 45317 is still most
279
+ * of a deck. It announces from its own socket, so every other deck still
280
+ * finds it, asks it and dials it; what it has lost is hearing them announce.
281
+ * Stopping the whole feature over that — which is what this did — took a
282
+ * working sync away from somebody whose Tailscale exit node happened to be
283
+ * holding the port. It keeps trying for the port instead, and takes it back
284
+ * the moment it is free.
285
+ */
286
+ let hearing = false;
287
+ let deafError = null;
288
+ /** Whether the last announce held back every broadcast because the local
289
+ * network goes through a tunnel here — see leavesByTunnel. */
290
+ let tunneled = false;
291
+ let told = null;
292
+ let rebind = null;
293
+ let stopped = true;
217
294
  /** When this deck last answered a deck it had not heard, so answering cannot
218
295
  * become a storm, and which decks it has already answered — without the
219
296
  * second, a deck that is never accepted is answered again on every packet
@@ -223,8 +300,8 @@ export function createBeacon({
223
300
 
224
301
  const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
225
302
 
226
- const announce = () => {
227
- if (!sock) return;
303
+ const announce = (also = []) => {
304
+ if (!sock && !out) return;
228
305
  // EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
229
306
  //
230
307
  // This used to send only to 255.255.255.255, on the argument that the
@@ -246,11 +323,18 @@ export function createBeacon({
246
323
  // broadcast is filtered, and it is what Syncthing sends — and every
247
324
  // interface's own directed broadcast goes out beside it. A duplicate packet
248
325
  // costs one datagram; a missing one costs the whole feature.
249
- const targets = broadcastTargets(ifaces());
326
+ const plan = broadcastPlan(ifaces());
327
+ // Asked behind the send, and read from what was last answered: the first
328
+ // beacon after a start goes as it always did, and the ones after it know.
329
+ void routes?.want?.(plan.map(p => p.to));
330
+ const kept = routes ? plan.filter(p => !leavesByTunnel(p, routes.via(p.to), looksLikeTunnel)) : plan;
331
+ tunneled = kept.length === 0;
332
+ const targets = kept.map(p => p.to);
333
+ const from = out ?? sock;
250
334
  let left = targets.length;
251
335
  const failed = [];
252
336
  for (const to of targets) {
253
- sock.send(payload(), DISCOVERY_PORT, to, err => {
337
+ from.send(payload(), DISCOVERY_PORT, to, err => {
254
338
  if (err) failed.push(`${to} (${err.code ?? err.message})`);
255
339
  // REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
256
340
  // the ordinary state of a machine with a VPN up, and a panel that said
@@ -262,82 +346,181 @@ export function createBeacon({
262
346
  }
263
347
  });
264
348
  }
349
+ // AND ONE PACKET PER TAILNET MACHINE. Not part of the verdict above: a node
350
+ // that has gone to sleep since the list was read is the ordinary state of a
351
+ // laptop, and it says nothing about whether this deck can be discovered.
352
+ let direct = [];
353
+ try { direct = [...(unicast() ?? []), ...also]; } catch { direct = [...also]; }
354
+ for (const to of new Set(direct)) {
355
+ if (typeof to !== "string" || !to || targets.includes(to)) continue;
356
+ from.send(payload(), DISCOVERY_PORT, to, () => {});
357
+ }
265
358
  };
266
359
 
267
- const start = () => new Promise(resolve => {
268
- sock = createSocket({ type: "udp4", reuseAddr: true });
269
- sock.on("error", err => { onError?.("socket", err); });
270
- sock.on("message", (msg, rinfo) => {
271
- // Everything about whether to care lives in lan-sync.mjs. This hands it
272
- // the bytes and the address and does what it is told.
273
- if (msg.length > MAX_BEACON_BYTES) return;
274
- const beacon = readBeacon(msg);
275
- const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
276
- // ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
277
- // part is the change. It used to answer only a deck already in the group,
278
- // which was fine when a group existed. Now the first thing a new deck has
279
- // to become is a row on somebody's screen, and it cannot become one if
280
- // this deck never tells it that it exists.
281
- //
282
- // Measured on two real decks before any of this: deck 1 saw deck 2 the
283
- // instant it started and deck 2 saw nobody, because deck 1's own
284
- // immediate announce went out before deck 2 was listening. Thirty seconds
285
- // of an empty list is how a working feature reads as broken.
286
- //
287
- // At most once every few seconds, because the obvious version is a shout
288
- // storm: two decks answering each other's answers forever. A new pair
289
- // converges in two extra packets.
290
- const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
291
- && !peers.has(beacon.fp) && !answered.has(beacon.fp);
292
- if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
293
- repliedAt = now();
294
- answered.add(beacon.fp);
295
- announce();
296
- }
297
- if (verdict !== "peer") {
298
- // Another deck is using this one's key — see beaconVerdict. Reported
299
- // rather than fixed here: this file carries packets, and choosing a new
300
- // identity for the deck belongs to whoever stores it.
301
- if (verdict === "id-clash") onIdClash?.();
302
- // A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
303
- // dropped: it is a name and an address on the same network, which is a
304
- // row somebody can accept. Nothing is asked of it and nothing is
305
- // offered to it until they do.
306
- if (verdict === "stranger") {
307
- onStranger?.({
308
- fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
309
- // Carried through so the list can show one row per machine rather
310
- // than one per key that machine has ever held.
311
- host: beacon.host, at: now(),
312
- });
313
- }
314
- return;
360
+ const onMessage = (msg, rinfo) => {
361
+ // Everything about whether to care lives in lan-sync.mjs. This hands it
362
+ // the bytes and the address and does what it is told.
363
+ if (msg.length > MAX_BEACON_BYTES) return;
364
+ let via = "lan";
365
+ try { via = routeFor(rinfo.address); } catch { via = "lan"; }
366
+ if (!via) return;
367
+ const beacon = readBeacon(msg);
368
+ const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
369
+ // ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
370
+ // part is the change. It used to answer only a deck already in the group,
371
+ // which was fine when a group existed. Now the first thing a new deck has
372
+ // to become is a row on somebody's screen, and it cannot become one if
373
+ // this deck never tells it that it exists.
374
+ //
375
+ // Measured on two real decks before any of this: deck 1 saw deck 2 the
376
+ // instant it started and deck 2 saw nobody, because deck 1's own
377
+ // immediate announce went out before deck 2 was listening. Thirty seconds
378
+ // of an empty list is how a working feature reads as broken.
379
+ //
380
+ // At most once every few seconds, because the obvious version is a shout
381
+ // storm: two decks answering each other's answers forever. A new pair
382
+ // converges in two extra packets.
383
+ const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
384
+ && !peers.has(beacon.fp) && !answered.has(beacon.fp);
385
+ if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
386
+ repliedAt = now();
387
+ answered.add(beacon.fp);
388
+ // A deck that reached this one over the tailnet is answered there too:
389
+ // a broadcast never gets back down its tunnel.
390
+ announce(via === "tailscale" ? [rinfo.address] : []);
391
+ }
392
+ if (verdict !== "peer") {
393
+ // Another deck is using this one's key — see beaconVerdict. Reported
394
+ // rather than fixed here: this file carries packets, and choosing a new
395
+ // identity for the deck belongs to whoever stores it.
396
+ if (verdict === "id-clash") onIdClash?.();
397
+ // A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
398
+ // dropped: it is a name and an address on the same network, which is a
399
+ // row somebody can accept. Nothing is asked of it and nothing is
400
+ // offered to it until they do.
401
+ if (verdict === "stranger") {
402
+ onStranger?.({
403
+ fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
404
+ // Carried through so the list can show one row per machine rather
405
+ // than one per key that machine has ever held.
406
+ host: beacon.host, at: now(), via,
407
+ });
315
408
  }
316
- const noted = notePeer(peers, beacon, rinfo.address, now());
317
- if (noted.changed || noted.restarted) onPeer?.(noted);
409
+ return;
410
+ }
411
+ const noted = notePeer(peers, beacon, rinfo.address, now(), via);
412
+ if (noted.changed || noted.restarted) onPeer?.(noted);
413
+ };
414
+
415
+ /** Tell whoever asked, once per change rather than once per try. */
416
+ const tell = now => {
417
+ if (told === now) return;
418
+ told = now;
419
+ onHearing?.(now);
420
+ };
421
+
422
+ /** One try at the discovery port: the listening socket, or the error that
423
+ * kept it. A bind that fails never calls back; it arrives as an error. */
424
+ const listen = () => new Promise(resolve => {
425
+ let s;
426
+ try { s = createSocket({ type: "udp4", reuseAddr: true }); } catch (err) { resolve({ err }); return; }
427
+ let bound = false;
428
+ s.on("error", err => {
429
+ if (bound) { onError?.("socket", err); return; }
430
+ try { s.close(); } catch { /* never opened */ }
431
+ resolve({ err });
318
432
  });
319
- sock.bind(DISCOVERY_PORT, "0.0.0.0", () => {
320
- try { sock.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
321
- // Immediately, not on the next tick. Syncthing's rule: a deck that just
322
- // came up should appear now rather than up to thirty seconds later, which
323
- // is the difference between "it works" and "it seems broken" for anybody
324
- // who starts two decks and watches.
325
- announce();
326
- timer = setInterval(announce, ANNOUNCE_MS);
327
- timer.unref?.();
328
- resolve();
433
+ s.on("message", onMessage);
434
+ s.bind(DISCOVERY_PORT, "0.0.0.0", () => {
435
+ bound = true;
436
+ try { s.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
437
+ resolve({ sock: s });
329
438
  });
330
439
  });
331
440
 
441
+ const tryListen = async () => {
442
+ const got = await listen();
443
+ if (stopped) { try { got.sock?.close(); } catch { /* gone */ } return; }
444
+ if (got.sock) {
445
+ sock = got.sock;
446
+ hearing = true;
447
+ deafError = null;
448
+ tell(true);
449
+ return;
450
+ }
451
+ hearing = false;
452
+ deafError = got.err;
453
+ tell(false);
454
+ rebind = setTimeout(() => { rebind = null; void tryListen(); }, rebindMs);
455
+ rebind.unref?.();
456
+ };
457
+
458
+ /**
459
+ * The socket beacons leave by, bound to whatever port the OS gives it.
460
+ *
461
+ * SENT FROM A PORT OF ITS OWN, NOT FROM 45317. Nothing that hears a beacon
462
+ * reads the port it came from — the reply goes to 45317 whatever the source
463
+ * — and sending from the discovery port gave that port away. Measured on a
464
+ * Mac that is a Tailscale exit node: a deck elsewhere on the tailnet routed a
465
+ * beacon through it, and Tailscale's forwarder binds its end of every UDP
466
+ * flow to the CLIENT's source port (netstack.go, forwardUDP), idling it out
467
+ * after two minutes. A beacon every thirty seconds never idles, so the Mac's
468
+ * own deck could never bind 45317 again. From an ephemeral port, the
469
+ * forwarder takes an ephemeral port.
470
+ */
471
+ const openOut = () => new Promise(resolve => {
472
+ let o;
473
+ try { o = createSocket({ type: "udp4" }); } catch { resolve(null); return; }
474
+ let opened = false;
475
+ o.on("error", err => {
476
+ if (opened) { onError?.("socket", err); return; }
477
+ opened = true;
478
+ try { o.close(); } catch { /* never opened */ }
479
+ resolve(null);
480
+ });
481
+ o.bind(0, "0.0.0.0", () => {
482
+ if (opened) return;
483
+ opened = true;
484
+ try { o.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
485
+ resolve(o);
486
+ });
487
+ });
488
+
489
+ const start = async () => {
490
+ stopped = false;
491
+ told = null;
492
+ await tryListen();
493
+ out = await openOut();
494
+ if (stopped) return;
495
+ // Immediately, not on the next tick. Syncthing's rule: a deck that just
496
+ // came up should appear now rather than up to thirty seconds later, which
497
+ // is the difference between "it works" and "it seems broken" for anybody
498
+ // who starts two decks and watches.
499
+ announce();
500
+ timer = setInterval(announce, ANNOUNCE_MS);
501
+ timer.unref?.();
502
+ };
503
+
332
504
  return {
333
505
  start,
334
506
  announce,
335
507
  peers,
508
+ /** Whether this deck holds the discovery port, and why not when it does
509
+ * not. */
510
+ hearing: () => hearing,
511
+ deafError: () => deafError,
512
+ tunneled: () => tunneled,
336
513
  stop() {
514
+ stopped = true;
337
515
  if (timer) clearInterval(timer);
338
516
  timer = null;
517
+ if (rebind) clearTimeout(rebind);
518
+ rebind = null;
519
+ hearing = false;
339
520
  try { sock?.close(); } catch { /* already closed */ }
340
521
  sock = null;
522
+ try { out?.close(); } catch { /* already closed */ }
523
+ out = null;
341
524
  },
342
525
  };
343
526
  }
@@ -894,13 +894,26 @@ export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mi
894
894
  * `changed` is returned rather than inferred by the caller so a beacon that
895
895
  * says nothing new — which is most of them, one every thirty seconds per peer
896
896
  * forever — costs no render and no write.
897
+ *
898
+ * `via` is the route the packet came by: "lan", or "tailscale" for one sent to
899
+ * this machine's tailnet address — see routeOf in tailscale.mjs.
897
900
  */
898
- export function notePeer(peers, beacon, addr, now) {
901
+ export function notePeer(peers, beacon, addr, now, via = "lan") {
899
902
  const prev = peers.get(beacon.fp);
903
+ // ONE MACHINE, TWO ROUTES, AND THE LOCAL ONE WINS WHILE IT ANSWERS. A laptop
904
+ // in the office is heard on the Wi-Fi and over Tailscale in the same half
905
+ // minute, and taking whichever packet came last flipped its address every
906
+ // beacon — each flip a `changed`, and a round dialling a different route each
907
+ // minute. The tailnet address is used only once the local one has gone quiet
908
+ // for as long as presence lasts, which is the laptop having left the building.
909
+ const lanAt = via === "lan" ? now : prev && prev.via !== "tailscale" ? prev.lanAt ?? prev.lastSeen : prev?.lanAt;
910
+ const keepLan = via === "tailscale" && prev && prev.via !== "tailscale" && lanAt != null && now - lanAt < PRESENT_MS;
900
911
  const next = {
901
912
  fp: beacon.fp,
902
913
  name: beacon.name,
903
- addr,
914
+ addr: keepLan ? prev.addr : addr,
915
+ via: keepLan ? prev.via ?? "lan" : via,
916
+ lanAt: lanAt ?? null,
904
917
  port: beacon.port,
905
918
  instance: beacon.instance,
906
919
  host: beacon.host,
@@ -31,6 +31,7 @@
31
31
  // (crashPolicy, in supervisor.mjs), it is identical on every platform, and it is
32
32
  // tested. Two policies over one process is how `ccdeck --stop` becomes a
33
33
  // suggestion the machine overrules a second later.
34
+ import { inApp } from "./app-host.mjs";
34
35
  import { spawnSync } from "node:child_process";
35
36
  import { homedir } from "node:os";
36
37
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -264,6 +265,9 @@ export function taskXmlFor({ execPath, script, args = [], product = "ccdeck" } =
264
265
  */
265
266
  export function shouldOfferService({ record = null, npx = false, checkout = false, env = process.env } = {}) {
266
267
  if (env.AGENTS_DECK_NO_INSTALL === "1") return false;
268
+ // The desktop app owns start-at-login; a second login item from the deck it
269
+ // started would launch that deck with no app around it (app-host.mjs).
270
+ if (inApp(env)) return false;
267
271
  if (npx || checkout) return false;
268
272
  return record === null;
269
273
  }
@@ -0,0 +1,190 @@
1
+ // The network section's readings, as pure functions of what the platform prints.
2
+ //
3
+ // Everything here is text in, numbers out, so a Linux answer can be checked from
4
+ // a Mac and a Windows one from either — the same reason every parser in
5
+ // system-metrics.mjs is exported and pure. The sampler that runs the commands,
6
+ // times the connection and keeps the history lives there; this file only reads.
7
+ //
8
+ // THREE READINGS, AND WHY THESE THREE (#network). The first build of this idea
9
+ // was a speed test, and a speed test is the wrong instrument twice over: it
10
+ // fills the line for as long as it runs — which is exactly the stall a person
11
+ // opens this section to explain — and the maximum a line can carry is not what
12
+ // was wrong the day this was designed. That day the Wi-Fi measured 270 Mbit/s
13
+ // and Claude FM still went silent for twenty seconds at a time, because every
14
+ // packet was leaving through a Tailscale exit node on another continent's relay.
15
+ // So the section reports what is MOVING (throughput), how FAR Claude is
16
+ // (latency), and which WAY the traffic goes (the route) — the three things a
17
+ // person needs to tell "the network is slow" from "the network is fine and
18
+ // something else is".
19
+
20
+ /**
21
+ * Bytes received and sent across the interfaces that are wires, out of
22
+ * `/proc/net/dev`.
23
+ *
24
+ * `include` decides which interfaces count, and the sampler passes the set of
25
+ * physical ones (those with a `device` under /sys/class/net). Counting every
26
+ * interface would count a VPN's traffic twice — once inside the tunnel and once
27
+ * again, encrypted, on the Wi-Fi that carries it — and loopback's traffic,
28
+ * which never leaves the machine, would read as the deck downloading.
29
+ *
30
+ * Returns null when nothing matched, which is not the same as zero: a machine
31
+ * whose counters read zero moved nothing, and one this could not read did not
32
+ * say.
33
+ */
34
+ export function parseProcNetDev(text, include = name => name !== "lo") {
35
+ if (typeof text !== "string") return null;
36
+ let rx = 0;
37
+ let tx = 0;
38
+ const ifaces = [];
39
+ for (const line of text.split("\n")) {
40
+ const colon = line.indexOf(":");
41
+ if (colon < 0) continue;
42
+ const name = line.slice(0, colon).trim();
43
+ if (!name || name.includes("|") || !include(name)) continue;
44
+ const f = line.slice(colon + 1).trim().split(/\s+/).map(Number);
45
+ // Receive is the first eight columns and transmit the next eight; bytes
46
+ // lead each group.
47
+ if (f.length < 9 || !Number.isFinite(f[0]) || !Number.isFinite(f[8])) continue;
48
+ rx += f[0];
49
+ tx += f[8];
50
+ ifaces.push(name);
51
+ }
52
+ return ifaces.length ? { rx, tx, ifaces } : null;
53
+ }
54
+
55
+ /**
56
+ * The same two totals out of macOS's `netstat -ibn`.
57
+ *
58
+ * Only the `enN` rows, which are the Mac's Ethernet and Wi-Fi ports, and only
59
+ * their `<Link#N>` row — netstat prints one row per address family per
60
+ * interface, and summing all of them would count every byte two or three times.
61
+ * `utun` (every VPN, Tailscale included), `lo`, `awdl`, `llw`, `bridge` and the
62
+ * rest are left out for the reason the Linux reader leaves virtual interfaces
63
+ * out. Read from the END of the row, because the Address column is empty on
64
+ * some interfaces and a count from the front would shift by one.
65
+ */
66
+ export function parseNetstatIbn(text) {
67
+ if (typeof text !== "string") return null;
68
+ let rx = 0;
69
+ let tx = 0;
70
+ let seen = 0;
71
+ for (const line of text.split("\n")) {
72
+ const f = line.trim().split(/\s+/);
73
+ if (f.length < 8 || !/^en\d+$/.test(f[0]) || !f.some(c => c.startsWith("<Link#"))) continue;
74
+ // … Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
75
+ const ibytes = Number(f[f.length - 5]);
76
+ const obytes = Number(f[f.length - 2]);
77
+ if (!Number.isFinite(ibytes) || !Number.isFinite(obytes)) continue;
78
+ rx += ibytes;
79
+ tx += obytes;
80
+ seen++;
81
+ }
82
+ return seen ? { rx, tx } : null;
83
+ }
84
+
85
+ /**
86
+ * The two totals out of Windows' `netstat -e`.
87
+ *
88
+ * Found by shape rather than by label: the first row that is a word followed by
89
+ * exactly two integers is the Bytes row, in every language Windows ships —
90
+ * "Bytes" is "Octets" on a French machine, and a parser that matched the word
91
+ * would read nothing there. The totals cover every adapter; Windows offers no
92
+ * cheaper per-adapter figure than a PowerShell start, which costs more than
93
+ * this whole section is worth every five seconds.
94
+ */
95
+ export function parseNetstatE(text) {
96
+ if (typeof text !== "string") return null;
97
+ for (const line of text.split(/\r?\n/)) {
98
+ const m = /^\s*\S[^\d]*?\s+(\d+)\s+(\d+)\s*$/.exec(line);
99
+ if (m) return { rx: Number(m[1]), tx: Number(m[2]) };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Bytes per second between two readings, or null when the pair cannot say.
106
+ *
107
+ * A counter that went BACKWARDS is an adapter that was reset — unplugged,
108
+ * resumed from sleep, a driver reloaded — and the difference across a reset is
109
+ * not a rate, it is a large negative number. Too short a gap is refused for the
110
+ * opposite reason: two reads a few milliseconds apart turn one packet into a
111
+ * wild figure.
112
+ */
113
+ export function rateBetween(prev, next) {
114
+ if (!prev || !next) return null;
115
+ const seconds = (next.at - prev.at) / 1000;
116
+ if (!(seconds >= 0.5)) return null;
117
+ const drx = next.rx - prev.rx;
118
+ const dtx = next.tx - prev.tx;
119
+ if (drx < 0 || dtx < 0) return null;
120
+ return { down: Math.round(drx / seconds), up: Math.round(dtx / seconds) };
121
+ }
122
+
123
+ /** The interface Linux would send a packet for this address out of, from
124
+ * `ip -o route get <address>`. Asking sends nothing; it reads the routing
125
+ * table, including the policy tables a Tailscale exit node installs, which
126
+ * `/proc/net/route` does not show. */
127
+ export function routeIfaceLinux(text) {
128
+ if (typeof text !== "string") return null;
129
+ return /\bdev\s+(\S+)/.exec(text)?.[1] ?? null;
130
+ }
131
+
132
+ /** The same question on macOS, from `route -n get <address>`. */
133
+ export function routeIfaceDarwin(text) {
134
+ if (typeof text !== "string") return null;
135
+ return /^\s*interface:\s*(\S+)/m.exec(text)?.[1] ?? null;
136
+ }
137
+
138
+ /**
139
+ * What kind of path an interface name is.
140
+ *
141
+ * Direct is the ordinary case and draws nothing at all in the panel: only a
142
+ * route that is not the machine's own connection is worth a line. Names, not
143
+ * probes, because the kernel already named them — `tailscale0`, `wg0`,
144
+ * `nordlynx`, `utun4`, `tun0`, `ppp0`, `zt…` — and asking each VPN client
145
+ * whether it is running would cost a process per client per sample.
146
+ */
147
+ export function classifyRoute(iface) {
148
+ if (typeof iface !== "string" || !iface) return null;
149
+ if (/^tailscale\d*$/.test(iface)) return { kind: "tailscale", iface };
150
+ if (/^(wg\d|nordlynx|mullvad|proton|wgpia)/.test(iface)) return { kind: "vpn", iface, name: "WireGuard" };
151
+ if (/^zt/.test(iface)) return { kind: "vpn", iface, name: "ZeroTier" };
152
+ if (/^(utun|tun|tap|ppp|ipsec|gpd|cscotun|vpn)/.test(iface)) return { kind: "vpn", iface, name: null };
153
+ return { kind: "direct", iface };
154
+ }
155
+
156
+ /**
157
+ * The exit node carrying this machine's traffic, and whether it is reached
158
+ * directly or through one of Tailscale's relays, from `tailscale status --json`.
159
+ *
160
+ * The relay is the half that matters. A direct path to an exit node costs a
161
+ * hop; a relayed one sends every byte through a DERP server — `nue` is
162
+ * Nuremberg — and adds the round trip to wherever that is, twice. That is what
163
+ * made a live stream buffer every few minutes on the day this was written, on a
164
+ * line that was otherwise fine.
165
+ */
166
+ export function tailscaleExitFromStatus(json) {
167
+ let status = json;
168
+ if (typeof json === "string") {
169
+ try { status = JSON.parse(json); } catch { return null; }
170
+ }
171
+ if (!status || typeof status !== "object" || !status.ExitNodeStatus) return null;
172
+ const peer = Object.values(status.Peer ?? {}).find(p => p && p.ExitNode);
173
+ const node = peer?.HostName || peer?.DNSName?.split(".")[0] || null;
174
+ // CurAddr is set when the path is direct; an empty one with a Relay named is
175
+ // every packet going through that relay.
176
+ const relay = peer && !peer.CurAddr && peer.Relay ? String(peer.Relay) : null;
177
+ return { node, relay };
178
+ }
179
+
180
+ /** One line for a route, the same words the panel and the history use, so the
181
+ * two can never describe one path differently. */
182
+ export function routeLabel(route) {
183
+ if (!route) return null;
184
+ if (route.kind === "direct") return "direct";
185
+ if (route.kind === "tailscale-exit") {
186
+ return `through Tailscale exit node${route.node ? ` ${route.node}` : ""}${route.relay ? `, relayed via ${route.relay}` : ""}`;
187
+ }
188
+ if (route.kind === "tailscale") return "through Tailscale";
189
+ return `through ${route.name ? `${route.name} ` : "a "}VPN${route.iface ? ` (${route.iface})` : ""}`;
190
+ }