ccdeck 3.25.0 → 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/dist/web/assets/{BrowserWatchModal-Chcet-7r.js → BrowserWatchModal-C5ouM-hi.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BIdS0lCL.js → UsageHistoryModal-D5mC-WNb.js} +1 -1
- package/dist/web/assets/index-DfvLgdJz.js +297 -0
- package/dist/web/assets/{index-7a7ISatq.css → index-DlaHgzye.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +2 -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 +67 -5
- 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/port-holder.mjs +66 -0
- package/src/server/route-via.mjs +109 -0
- package/src/server/self-update.mjs +6 -2
- package/src/server/tailscale.mjs +269 -0
- package/dist/web/assets/index-BoR2dMqR.js +0 -297
|
@@ -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,66 @@
|
|
|
1
|
+
// Which program holds a UDP port, in the words the LAN panel prints.
|
|
2
|
+
//
|
|
3
|
+
// A deck that cannot bind the discovery port says so, and "another program"
|
|
4
|
+
// is a sentence nobody can act on. On the Mac where this was measured the
|
|
5
|
+
// program was Tailscale, and finding that out took netstat and an hour. The
|
|
6
|
+
// operating system will usually say, without elevation, and when it will not
|
|
7
|
+
// the sentence stays the one it was.
|
|
8
|
+
//
|
|
9
|
+
// Pure readers per platform, and one runner that picks the command; nothing
|
|
10
|
+
// here is ever a reason for the deck to fail.
|
|
11
|
+
import { run as runCommand } from "./exec.mjs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* macOS: `netstat -anv -p udp` lists every UDP socket with its owner as
|
|
15
|
+
* `name:pid` in the process column — readable by a plain user for sockets of
|
|
16
|
+
* any user, which is the reason this is the Mac's answer.
|
|
17
|
+
*/
|
|
18
|
+
export function holderFromNetstat(text, port) {
|
|
19
|
+
const at = new RegExp(`[.:]${port}\\s`);
|
|
20
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
21
|
+
if (!/^udp/.test(line) || !at.test(line.split(/\s+/).slice(0, 5).join(" ") + " ")) continue;
|
|
22
|
+
const owner = line.split(/\s+/).find(f => /^[A-Za-z][\w.-]*:\d+$/.test(f));
|
|
23
|
+
if (owner) return owner.replace(/:\d+$/, "");
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Linux: `ss -H -uanp 'sport = :PORT'` — `users:(("name",pid=…))`, shown
|
|
29
|
+
* only for this user's own processes; somebody else's is an unnamed row. */
|
|
30
|
+
export function holderFromSs(text) {
|
|
31
|
+
const m = /users:\(\("([^"]+)"/.exec(String(text ?? ""));
|
|
32
|
+
return m ? m[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Windows: the owning process's name, one line, from the PowerShell below. */
|
|
36
|
+
export function holderFromPowershell(text) {
|
|
37
|
+
const name = String(text ?? "").split(/\r?\n/).map(l => l.trim()).find(Boolean);
|
|
38
|
+
return name && /^[\w .()-]{1,64}$/.test(name) ? name : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The name a person knows the program by. Tailscale is the one measured,
|
|
42
|
+
* and it goes by a different process name on each platform. */
|
|
43
|
+
export function friendlyHolder(name) {
|
|
44
|
+
if (!name) return null;
|
|
45
|
+
if (/tailscale/i.test(name)) return "Tailscale";
|
|
46
|
+
return name;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Who holds UDP `port` here, or null when the machine will not say. */
|
|
50
|
+
export async function portHolder(port, { platform = process.platform, run = runCommand } = {}) {
|
|
51
|
+
try {
|
|
52
|
+
if (platform === "darwin") {
|
|
53
|
+
const r = await run("netstat", ["-anv", "-p", "udp"], { timeout: 5_000 });
|
|
54
|
+
return friendlyHolder(r?.ok ? holderFromNetstat(r.stdout, port) : null);
|
|
55
|
+
}
|
|
56
|
+
if (platform === "win32") {
|
|
57
|
+
const ps = `Get-NetUDPEndpoint -LocalPort ${Number(port)} -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }`;
|
|
58
|
+
const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
|
|
59
|
+
return friendlyHolder(r?.ok ? holderFromPowershell(r.stdout) : null);
|
|
60
|
+
}
|
|
61
|
+
const r = await run("ss", ["-H", "-uanp", `sport = :${Number(port)}`], { timeout: 5_000 });
|
|
62
|
+
return friendlyHolder(r?.ok ? holderFromSs(r.stdout) : null);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Which interface the machine would send a packet to an address by.
|
|
2
|
+
//
|
|
3
|
+
// A BEACON FOR THE LOCAL NETWORK MUST STAY ON IT. A laptop using a Tailscale
|
|
4
|
+
// exit node, or any VPN that takes the local network too, routes even a
|
|
5
|
+
// broadcast for its own Wi-Fi into the tunnel — and on the far end it comes out
|
|
6
|
+
// on somebody else's network: an office Mac that is the exit node for a home
|
|
7
|
+
// laptop put the laptop's beacons on the office LAN, where a colleague whose
|
|
8
|
+
// network uses the same addresses could see the name of a machine that is
|
|
9
|
+
// nothing to do with them. So before a beacon goes, the machine is asked where
|
|
10
|
+
// it would go, and a beacon that would leave through a tunnel does not leave.
|
|
11
|
+
//
|
|
12
|
+
// Asked of the operating system rather than reasoned from interface names,
|
|
13
|
+
// because only the routing table knows what a VPN did to it: `route get` on
|
|
14
|
+
// macOS, `ip route get` on Linux, Find-NetRoute on Windows. The answers are
|
|
15
|
+
// cached, since routes change when networks do and not every thirty seconds,
|
|
16
|
+
// and a lookup that fails or cannot be made answers nothing — the beacon then
|
|
17
|
+
// goes as it always did.
|
|
18
|
+
import { run as runCommand } from "./exec.mjs";
|
|
19
|
+
|
|
20
|
+
/** How long one answer is kept. Long enough that a Windows machine starts one
|
|
21
|
+
* PowerShell every couple of minutes rather than every beacon; short enough
|
|
22
|
+
* that turning an exit node on stops the beacons into it soon after. */
|
|
23
|
+
export const ROUTE_TTL_MS = 120_000;
|
|
24
|
+
|
|
25
|
+
/** `route -n get <ip>` on macOS: the `interface:` line. */
|
|
26
|
+
export function viaFromRouteGet(text) {
|
|
27
|
+
const m = /^\s*interface:\s*(\S+)/m.exec(String(text ?? ""));
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `ip -o route get <ip>` on Linux: the device after `dev`. */
|
|
32
|
+
export function viaFromIpRoute(text) {
|
|
33
|
+
const m = /\bdev\s+(\S+)/.exec(String(text ?? ""));
|
|
34
|
+
return m ? m[1] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The PowerShell below prints `address|alias` per line on Windows. */
|
|
38
|
+
export function viaFromFindNetRoute(text) {
|
|
39
|
+
const out = new Map();
|
|
40
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
41
|
+
const at = line.indexOf("|");
|
|
42
|
+
if (at <= 0) continue;
|
|
43
|
+
const alias = line.slice(at + 1).trim();
|
|
44
|
+
if (alias) out.set(line.slice(0, at).trim(), alias);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether an interface is a tunnel rather than a network a person is on.
|
|
51
|
+
*
|
|
52
|
+
* Used only for the limited broadcast, which belongs to no one interface: a
|
|
53
|
+
* directed broadcast is judged by whether it leaves by the interface whose
|
|
54
|
+
* subnet it is, which needs no names at all. These are the names tunnels go
|
|
55
|
+
* by on the three platforms — macOS numbers every VPN `utun`, and a Windows
|
|
56
|
+
* adapter is called whatever its VPN named it.
|
|
57
|
+
*/
|
|
58
|
+
export function looksLikeTunnel(name) {
|
|
59
|
+
if (typeof name !== "string" || !name) return false;
|
|
60
|
+
return /^(utun|tun|tap|wg|ppp|ipsec|gpd|zt)\d*$/i.test(name)
|
|
61
|
+
|| /tailscale|wireguard|nordlynx|openvpn|vpn/i.test(name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const QUAD = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
65
|
+
|
|
66
|
+
/** Ask the machine, for every address in `targets`, which interface it would
|
|
67
|
+
* use. A Map of address to interface name; missing where it would not say. */
|
|
68
|
+
export async function lookupRoutes(targets, { platform = process.platform, run = runCommand } = {}) {
|
|
69
|
+
const list = targets.filter(t => typeof t === "string" && QUAD.test(t));
|
|
70
|
+
const out = new Map();
|
|
71
|
+
if (!list.length) return out;
|
|
72
|
+
if (platform === "win32") {
|
|
73
|
+
const quoted = list.map(t => `'${t}'`).join(",");
|
|
74
|
+
const ps = `foreach($t in @(${quoted})){ $r = Find-NetRoute -RemoteIPAddress $t -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceAlias } | Select-Object -First 1; $t + '|' + $r.InterfaceAlias }`;
|
|
75
|
+
const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
|
|
76
|
+
return r?.ok ? viaFromFindNetRoute(r.stdout) : out;
|
|
77
|
+
}
|
|
78
|
+
for (const t of list) {
|
|
79
|
+
const r = platform === "darwin"
|
|
80
|
+
? await run("route", ["-n", "get", t], { timeout: 3_000 })
|
|
81
|
+
: await run("ip", ["-o", "route", "get", t], { timeout: 3_000 });
|
|
82
|
+
const via = r?.ok ? (platform === "darwin" ? viaFromRouteGet(r.stdout) : viaFromIpRoute(r.stdout)) : null;
|
|
83
|
+
if (via) out.set(t, via);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The cache the beacon reads synchronously: `via(address)` is the last answer
|
|
90
|
+
* or undefined, and `want(addresses)` starts one lookup for whichever of them
|
|
91
|
+
* are missing or stale. Never more than one lookup at a time.
|
|
92
|
+
*/
|
|
93
|
+
export function createRouteCheck({ platform = process.platform, run = runCommand, now = Date.now, ttlMs = ROUTE_TTL_MS } = {}) {
|
|
94
|
+
const cache = new Map();
|
|
95
|
+
let busy = null;
|
|
96
|
+
return {
|
|
97
|
+
via: to => cache.get(to)?.via,
|
|
98
|
+
want(targets) {
|
|
99
|
+
if (busy) return busy;
|
|
100
|
+
const stale = (targets ?? []).filter(t => { const c = cache.get(t); return !c || now() - c.at > ttlMs; });
|
|
101
|
+
if (!stale.length) return Promise.resolve();
|
|
102
|
+
busy = lookupRoutes(stale, { platform, run })
|
|
103
|
+
.then(found => { for (const t of stale) cache.set(t, { via: found.get(t) ?? null, at: now() }); })
|
|
104
|
+
.catch(() => { /* nothing learned; the beacon goes as it always did */ })
|
|
105
|
+
.finally(() => { busy = null; });
|
|
106
|
+
return busy;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// it by name and only where it can actually work: a global install, on a
|
|
23
23
|
// directory we can write, outside a git checkout and outside an npx cache.
|
|
24
24
|
// Everywhere else this stays what it has always been — a printed command.
|
|
25
|
+
import { inApp } from "./app-host.mjs";
|
|
25
26
|
import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
26
27
|
import { spawn } from "node:child_process";
|
|
27
28
|
import { homedir } from "node:os";
|
|
@@ -1085,7 +1086,9 @@ export function upgradeBlock(pkgRoot) {
|
|
|
1085
1086
|
// npm -g rewrites the package directory and its parent (the global
|
|
1086
1087
|
// node_modules), so both have to be ours to write.
|
|
1087
1088
|
writable: dirWritable(target) && dirWritable(resolve(target, "..")),
|
|
1088
|
-
|
|
1089
|
+
// Inside the desktop app the app updates itself; an `npm i -g` here would
|
|
1090
|
+
// install a copy it never runs (app-host.mjs).
|
|
1091
|
+
optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1" || inApp(),
|
|
1089
1092
|
// Whether the package that write would fetch is still a deck. Kept out of
|
|
1090
1093
|
// the pure rule above, like every other input here, so the policy stays one
|
|
1091
1094
|
// readable expression and this file owns the filesystem half of it.
|
|
@@ -1461,7 +1464,8 @@ export async function versionReport({ running, pkgRoot, name = PUBLISHED_NAME, n
|
|
|
1461
1464
|
// isOlder decides that, not this.
|
|
1462
1465
|
const skipRegistry =
|
|
1463
1466
|
process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
|
|
1464
|
-
process.env.AGENTS_DECK_NO_INSTALL === "1"
|
|
1467
|
+
process.env.AGENTS_DECK_NO_INSTALL === "1" ||
|
|
1468
|
+
inApp();
|
|
1465
1469
|
const latest = skipRegistry ? null : await latestOnNpm(asked, now, force);
|
|
1466
1470
|
const marker = skipRegistry ? null : readMarker(asked);
|
|
1467
1471
|
const blocked = upgradeBlock(pkgRoot);
|