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.
- package/README.md +2 -2
- package/dist/web/assets/{BrowserWatchModal-tavADxex.js → BrowserWatchModal-C5ouM-hi.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BpWy0lAC.js → UsageHistoryModal-D5mC-WNb.js} +1 -1
- package/dist/web/assets/index-DfvLgdJz.js +297 -0
- package/dist/web/assets/index-DlaHgzye.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -1
- package/src/server/app-host.mjs +37 -0
- package/src/server/block-notify.mjs +106 -32
- package/src/server/browser-react.mjs +16 -1
- package/src/server/deck-prefs.mjs +16 -1
- package/src/server/index.mjs +72 -8
- package/src/server/installer.mjs +4 -1
- package/src/server/lan-engine.mjs +193 -16
- package/src/server/lan-socket.mjs +250 -67
- package/src/server/lan-sync.mjs +15 -2
- package/src/server/login-service.mjs +4 -0
- package/src/server/network-metrics.mjs +190 -0
- package/src/server/port-holder.mjs +66 -0
- package/src/server/route-via.mjs +109 -0
- package/src/server/self-update.mjs +6 -2
- package/src/server/system-metrics.mjs +262 -4
- package/src/server/tailscale.mjs +269 -0
- package/dist/web/assets/index-2fgjjBhR.css +0 -1
- package/dist/web/assets/index-CeRtWRS-.js +0 -296
|
@@ -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
|
-
|
|
79
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
}
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -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
|
+
}
|