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.
@@ -28,9 +28,10 @@
28
28
  // accounts all work talks to its peers every minute and never asks for
29
29
  // anything.
30
30
  import { accountKey, currentFor, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
- import { connectToPeer, createBeacon, createSyncServer, MAX_FRAME_BYTES } from "./lan-socket.mjs";
31
+ import { connectToPeer, createBeacon, createSyncServer, DISCOVERY_PORT, MAX_FRAME_BYTES } from "./lan-socket.mjs";
32
32
  import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
33
33
  import { openAbout, sealAbout } from "./lan-about.mjs";
34
+ import { beaconTargets, routeOf, IDLE_MS as TAILNET_IDLE_MS, TAILNET_MS } from "./tailscale.mjs";
34
35
  import { randomBytes } from "node:crypto";
35
36
  import { hostname, networkInterfaces } from "node:os";
36
37
 
@@ -54,6 +55,11 @@ export const SYNC_MS = 60_000;
54
55
  */
55
56
  export const ASKING_MS = 8_000;
56
57
 
58
+ /** How long to wait before trying the discovery port again while another
59
+ * program holds it. The beacon's own interval: a port that frees up is picked
60
+ * up about when the next beacon would have gone out anyway. */
61
+ export const BIND_RETRY_MS = 30_000;
62
+
57
63
  /** How long one peer round may take before it is abandoned. A manifest is one
58
64
  * round trip on a local network; anything past this is a peer that is not
59
65
  * going to answer, and holding the attempt open would stall the next round. */
@@ -222,12 +228,33 @@ export function createEngine({
222
228
  * unless the suite says loopback — a test deck has no business being
223
229
  * reachable from the office for the seconds it runs. */
224
230
  host,
231
+ /**
232
+ * The Tailscale reader — see createTailnet in tailscale.mjs — or nothing,
233
+ * which is a deck that knows only the local network. Injected because the
234
+ * real one spawns the CLI, and the suite's engines have no tailnet.
235
+ */
236
+ tailnet = null,
237
+ /** How long a deck that cannot hear waits to try the port again. A
238
+ * parameter so the suite does not wait thirty seconds to see it. */
239
+ bindRetryMs = BIND_RETRY_MS,
240
+ /** Which program holds the discovery port, when it is taken — see
241
+ * port-holder.mjs — so the panel can name it. Nothing asks without one. */
242
+ portHolder = null,
243
+ /** Where the machine would send each broadcast, so none leaves through a
244
+ * tunnel — see route-via.mjs. Absent, every broadcast goes. */
245
+ routes = null,
225
246
  } = {}) {
226
247
  let cfg = {
227
248
  enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
228
249
  autoAsk: true, autoAccept: true, aliases: {},
229
250
  // Tell paired decks which shared account this one is on — see currentFor.
230
251
  shareActive: true,
252
+ // DISCOVERY OVER TAILSCALE, off until somebody turns it on, and its own
253
+ // pair of permissions. They are separate from the two above because the
254
+ // tailnet is a different audience: the local switches answer for whoever
255
+ // is on this network, these only ever for machines signed in to this
256
+ // person's own Tailscale account — see routeOf.
257
+ tailscale: false, tailscaleAsk: true, tailscaleAccept: true,
231
258
  };
232
259
  let identity = null;
233
260
  let beacon = null;
@@ -320,6 +347,10 @@ export function createEngine({
320
347
  * is the evidence, and it is the same kind the beacon gives: a timestamp.
321
348
  */
322
349
  const spokeAt = new Map();
350
+ /** And the address it spoke FROM, so a deck that only ever calls in can
351
+ * still be said to come over the tailnet or the local network — it has no
352
+ * address of its own here, and without this its row could not say which. */
353
+ const spokeFrom = new Map();
323
354
  /**
324
355
  * When a connection from ANOTHER MACHINE last arrived on the sync listener.
325
356
  *
@@ -348,6 +379,55 @@ export function createEngine({
348
379
  * asked about. */
349
380
  let listeningSince = null;
350
381
 
382
+ /** The tailnet read's own timer, running only while the switch is on. */
383
+ let tailTimer = null;
384
+
385
+ /** Who held the discovery port the last time it was asked, for as long as
386
+ * this deck cannot hear: the answer does not change between two tries half a
387
+ * minute apart, and on Windows asking costs a PowerShell start. Undefined
388
+ * until asked, null when the machine would not say. */
389
+ let holder;
390
+
391
+ /** What the panel says while this deck cannot hear — see createBeacon's
392
+ * `hearing`. Null whenever it can. */
393
+ const deafLine = () => {
394
+ if (!beacon || beacon.hearing()) return null;
395
+ const err = beacon.deafError?.();
396
+ if (err && err.code !== "EADDRINUSE") {
397
+ return `This deck cannot listen on UDP ${DISCOVERY_PORT} (${err.code ?? err.message}), so it hears no other deck announce itself. Other decks still find it and pair with it.`;
398
+ }
399
+ return `${holder ?? "Another program"} is holding UDP ${DISCOVERY_PORT}, so this deck hears no new decks. Others still find it and pair with it, and it takes the port back as soon as it is free.`;
400
+ };
401
+
402
+ /** Whether an address is a tailnet one, and whose. Null is the local network
403
+ * — and always is on a deck with no Tailscale reader. */
404
+ const routeTo = addr => routeOf(tailnet?.snapshot?.() ?? null, addr);
405
+
406
+ /** The two permissions that answer for one route. */
407
+ const asksOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
408
+ const saysYesOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
409
+
410
+ /**
411
+ * Read the tailnet on a timer while the switch is on, and not at all while it
412
+ * is off — the read at start covers telling a tailnet address from a local
413
+ * one, and the dialog's own poll covers whether Tailscale is there at all.
414
+ *
415
+ * TURNING IT ON ANNOUNCES AT ONCE, after one read, so the owner's machines
416
+ * hear about this one in the second after the press rather than on the next
417
+ * beacon, up to half a minute later.
418
+ */
419
+ const syncTailnet = () => {
420
+ const want = !!(beacon && tailnet && cfg.enabled && cfg.tailscale);
421
+ if (want && !tailTimer) {
422
+ void tailnet.refresh().then(() => beacon?.announce(), () => {});
423
+ tailTimer = setInterval(() => { void tailnet.refresh(); }, TAILNET_MS);
424
+ tailTimer.unref?.();
425
+ } else if (!want && tailTimer) {
426
+ clearInterval(tailTimer);
427
+ tailTimer = null;
428
+ }
429
+ };
430
+
351
431
  /** This deck's accounts in the shape the rules want. Read through the same
352
432
  * function the panel uses, so a row can never be alive here and dead there. */
353
433
  const localAccounts = async () => {
@@ -380,7 +460,11 @@ export function createEngine({
380
460
  const serve = async (msg, ctx) => {
381
461
  // Before the verbs, and for every one of them: something that proved it
382
462
  // holds a key this deck accepted is talking, now.
383
- if (ctx?.peerFp) spokeAt.set(ctx.peerFp, now());
463
+ if (ctx?.peerFp) {
464
+ spokeAt.set(ctx.peerFp, now());
465
+ const from = String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
466
+ if (from) spokeFrom.set(ctx.peerFp, from);
467
+ }
384
468
  try {
385
469
  if (msg.t === "manifest") {
386
470
  // THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
@@ -456,8 +540,15 @@ export function createEngine({
456
540
  const askToAccept = entry => {
457
541
  if (declined.has(entry.fp)) return;
458
542
  const had = pending.get(entry.fp);
459
- pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
460
- if (cfg.autoAccept) { engine?.accept(entry.fp); return; }
543
+ // WHICH SWITCH ANSWERS depends on where the deck is. A request from the
544
+ // tailnet is answered by the Tailscale pair, and only for a machine on this
545
+ // person's own Tailscale account; anything else on the tailnet waits for a
546
+ // press whatever either switch says. See routeOf.
547
+ const route = routeTo(entry.addr);
548
+ const via = route ? "tailscale" : "lan";
549
+ const own = !!route?.own;
550
+ pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
551
+ if (saysYesOn(via) && (via === "lan" || own)) { engine?.accept(entry.fp); return; }
461
552
  if (!had) onChange?.();
462
553
  };
463
554
 
@@ -693,9 +784,26 @@ export function createEngine({
693
784
  // twice a round and its work counted twice.
694
785
  // The same rule the list uses. A deck that has been silent for a day is not
695
786
  // dialled once a minute forever on the chance it comes back.
696
- const heard = [...beacon.peers.values()].filter(p => stillListed(p, now()));
787
+ //
788
+ // A deck heard over the tailnet is dialled only while that switch is on, and
789
+ // so is a row the deck added itself from a tailnet beacon. An address a
790
+ // person typed is theirs whatever the switch says — pairing by a typed
791
+ // 100.x address worked before any of this.
792
+ const heard = [...beacon.peers.values()]
793
+ .filter(p => stillListed(p, now()) && (cfg.tailscale || p.via !== "tailscale"));
697
794
  const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
698
- for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
795
+ // AND ONE DIAL PER DECK, not one per address. A row asked from a tailnet
796
+ // beacon keeps that address after the same deck is heard on the local
797
+ // network, and both used to be dialled every round — two handshakes, and
798
+ // two lines of work for one machine. `learned` says which deck a row
799
+ // reached; the heard row already dials it by the better route.
800
+ const heardFps = new Set(heard.map(p => p.fp));
801
+ const typed = [...manual.values()].filter(p => {
802
+ const at = `${p.addr}:${p.port}`;
803
+ if (seen.has(at) || heardFps.has(learned.get(at)?.fp)) return false;
804
+ return cfg.tailscale || p.typed || !routeTo(p.addr);
805
+ });
806
+ for (const peer of [...heard, ...typed]) {
699
807
  // Sequential rather than parallel. The store takes one mutation at a
700
808
  // time anyway (the mutex in store-lock.mjs), and two peers healing the
701
809
  // same account at once would race for a slot number claude-swap assigns
@@ -772,18 +880,28 @@ export function createEngine({
772
880
  // this on with two rows sitting in the panel means those two as much as
773
881
  // the next one, and leaving them queued behind a setting called
774
882
  // "automatic" is the switch not doing what it says.
775
- if (!was.autoAccept && cfg.autoAccept) {
776
- for (const fp of [...pending.keys()]) this.accept(fp);
777
- }
883
+ //
884
+ // PER ROUTE, because each pair of switches answers for its own: turning
885
+ // the local one on does not answer a tailnet request, and turning the
886
+ // Tailscale one on answers only the owner's own machines.
887
+ const yes = c => ({ lan: !!c.autoAccept, tailscale: !!c.tailscale && c.tailscaleAccept !== false });
888
+ const ask = c => ({ lan: !!c.autoAsk, tailscale: !!c.tailscale && c.tailscaleAsk !== false });
889
+ const turnedOn = (f, via) => !f(was)[via] && f(cfg)[via];
890
+ const mayAnswer = p => (p.via === "tailscale" ? turnedOn(yes, "tailscale") && p.own : turnedOn(yes, "lan"));
891
+ for (const [fp, p] of [...pending]) if (mayAnswer(p)) this.accept(fp);
778
892
  // The same for the other direction: switching `ask` on with four machines
779
893
  // already listed asks those four.
780
- if (!was.autoAsk && cfg.autoAsk) {
781
- for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub) this.accept(fp, { byHand: false });
894
+ const mayAsk = p => (p.via === "tailscale" ? turnedOn(ask, "tailscale") && p.own : turnedOn(ask, "lan"));
895
+ for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub && mayAsk(p)) this.accept(fp, { byHand: false });
896
+ // OFF MEANS THE TAILNET GOES QUIET HERE: nobody heard over it is offered,
897
+ // and syncTailnet stops the reads. Decks already paired stay paired.
898
+ if (was.tailscale && !cfg.tailscale) {
899
+ for (const [fp, p] of [...strangers]) if (p.via === "tailscale") strangers.delete(fp);
782
900
  }
783
901
  const restart = !was.enabled !== !cfg.enabled
784
902
  || was.secret !== cfg.secret
785
903
  || was.name !== cfg.name;
786
- if (!restart) return;
904
+ if (!restart) { syncTailnet(); return; }
787
905
  this.stop();
788
906
  if (!cfg.enabled) return;
789
907
  identity = identityFrom(cfg.secret);
@@ -856,7 +974,24 @@ export function createEngine({
856
974
  beacon = createBeacon({
857
975
  port, name: cfg.name, fp: identity.fp,
858
976
  trusted: () => cfg.trusted,
977
+ // The owner's own machines on the tailnet, while the switch is on.
978
+ unicast: () => (cfg.tailscale ? beaconTargets(tailnet?.snapshot?.() ?? null) : []),
979
+ routeFor: addr => (!routeTo(addr) ? "lan" : cfg.tailscale ? "tailscale" : null),
859
980
  onPeer: () => onChange?.(),
981
+ // HEARING, AS OPPOSED TO RUNNING. A deck whose discovery port another
982
+ // program holds keeps everything else — the listener, the rounds, its
983
+ // own beacon — and says in the panel who has the port. Asked once per
984
+ // spell, behind the sentence that does not need the name.
985
+ rebindMs: bindRetryMs,
986
+ routes,
987
+ onHearing: now => {
988
+ if (now) { holder = undefined; onChange?.(); return; }
989
+ if (holder === undefined && portHolder && beacon?.deafError?.()?.code === "EADDRINUSE") {
990
+ holder = null;
991
+ void Promise.resolve().then(() => portHolder()).then(who => { holder = who ?? null; onChange?.(); }, () => {});
992
+ }
993
+ onChange?.();
994
+ },
860
995
  onStranger: entry => {
861
996
  const had = strangers.get(entry.fp);
862
997
  // KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
@@ -864,7 +999,8 @@ export function createEngine({
864
999
  // design — used to leave its old key in this map for a day, and every
865
1000
  // one of them drew a row offering to pair with the same machine.
866
1001
  if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
867
- strangers.set(entry.fp, entry);
1002
+ const own = entry.via === "tailscale" && !!routeTo(entry.addr)?.own;
1003
+ strangers.set(entry.fp, { ...entry, own });
868
1004
  // ASK IT, which is what the `ask` verb on its row does and nothing
869
1005
  // more: the address goes on the dial list and the next round sends a
870
1006
  // request that somebody over there still has to answer. A beacon
@@ -876,7 +1012,10 @@ export function createEngine({
876
1012
  // already turned away.
877
1013
  // Never `byHand`: a beacon is a shout from an address nobody here
878
1014
  // named, and the row it leaves may ask rather than pin.
879
- if (cfg.autoAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
1015
+ // Over the tailnet only a machine on this person's own account is
1016
+ // asked unprompted; any other is a row for somebody to decide on.
1017
+ const mayAsk = asksOn(entry.via) && (entry.via !== "tailscale" || own);
1018
+ if (mayAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
880
1019
  // Only a deck that is new to us is news. A beacon every thirty
881
1020
  // seconds from one already on the list is not a reason to redraw.
882
1021
  if (!had) onChange?.();
@@ -893,6 +1032,10 @@ export function createEngine({
893
1032
  ...(createSocket ? { createSocket } : {}),
894
1033
  });
895
1034
  await beacon.start();
1035
+ // One read of the tailnet whatever the switch says, so a packet from a
1036
+ // tailnet address is told apart from a local one from the first minute.
1037
+ void tailnet?.freshen?.(TAILNET_IDLE_MS);
1038
+ syncTailnet();
896
1039
  // A self-scheduling loop rather than one interval, because the gap
897
1040
  // between rounds is not one number: see ASKING_MS.
898
1041
  const tick = async () => {
@@ -1201,6 +1344,11 @@ export function createEngine({
1201
1344
  // Said only while it is true, and it is only ever true of a deck that
1202
1345
  // is switched on and has no listener.
1203
1346
  stalled: cfg.enabled && !beacon ? stalled : null,
1347
+ // Running, and unable to hear other decks announce — see deafLine.
1348
+ deaf: deafLine(),
1349
+ // Every local broadcast held back, because this machine sends its
1350
+ // local network through a tunnel — see leavesByTunnel.
1351
+ lanTunneled: !!beacon?.tunneled?.(),
1204
1352
  // When every paired deck was last asked. Null until the first round,
1205
1353
  // which on a deck that has just started is the honest answer.
1206
1354
  checkedAt: roundAt,
@@ -1216,6 +1364,24 @@ export function createEngine({
1216
1364
  autoAccept: !!cfg.autoAccept,
1217
1365
  // Whether paired decks are told which shared account this one is on.
1218
1366
  shareActive: cfg.shareActive !== false,
1367
+ // Discovery over Tailscale: whether this machine has it at all, which
1368
+ // decides whether the dialog shows the switch, and what it can see.
1369
+ tailscale: tailnet ? (() => {
1370
+ const t = tailnet.snapshot?.() ?? null;
1371
+ return {
1372
+ found: !!tailnet.found?.(),
1373
+ state: t?.state ?? null,
1374
+ running: !!t?.running,
1375
+ on: !!cfg.tailscale,
1376
+ ask: cfg.tailscaleAsk !== false,
1377
+ accept: cfg.tailscaleAccept !== false,
1378
+ login: t?.self?.login ?? null,
1379
+ addr: t?.self?.ips?.[0] ?? null,
1380
+ exitNode: !!t?.exitNode,
1381
+ // The owner's machines a beacon goes to right now.
1382
+ devices: beaconTargets(t).length,
1383
+ };
1384
+ })() : null,
1219
1385
  fp: identity?.fp ?? null,
1220
1386
  // The address and port a person on another subnet types into the other
1221
1387
  // deck's field. Null when this machine has no ordinary one, which the
@@ -1238,7 +1404,7 @@ export function createEngine({
1238
1404
  // and decks merely heard. Three lists because they are three different
1239
1405
  // things a person does something different about.
1240
1406
  trusted: cfg.trusted.map(t => ({ fp: t.fp, name: t.name })),
1241
- pending: [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
1407
+ pending: [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at, via: p.via ?? "lan", own: !!p.own })),
1242
1408
  // Only the ones somebody could actually pair with right now, one row
1243
1409
  // per machine, newest first — see pairable, which is where the rule
1244
1410
  // that keeps this from becoming a wall of ghosts lives.
@@ -1250,7 +1416,10 @@ export function createEngine({
1250
1416
  // that has run the deck a few times holds a key per run, and every
1251
1417
  // one of them was a row of its own on everybody else's panel.
1252
1418
  const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
1253
- return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
1419
+ return shown.map(p => ({
1420
+ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more,
1421
+ via: p.via ?? "lan", own: !!p.own,
1422
+ }));
1254
1423
  })(),
1255
1424
  // Said no to, by somebody at this keyboard. Listed rather than merely
1256
1425
  // silenced, because a refusal nobody can see is a refusal nobody can
@@ -1296,6 +1465,9 @@ export function createEngine({
1296
1465
  put({
1297
1466
  ...p,
1298
1467
  id,
1468
+ // How it is reached. A heard row says which route its last beacon
1469
+ // took; a typed one is read from its address.
1470
+ via: p.via ?? (routeTo(p.addr) ? "tailscale" : "lan"),
1299
1471
  // The fingerprint an unpair has to name. A typed row's own `fp` is
1300
1472
  // a placeholder built from its address and matches nothing.
1301
1473
  peerFp: p.manual ? met?.fp ?? null : p.fp,
@@ -1319,6 +1491,8 @@ export function createEngine({
1319
1491
  // and this is when. Undefined until it has, which is a row the
1320
1492
  // panel draws as unknown rather than as live.
1321
1493
  lastSeen: spokeAt.get(t.fp),
1494
+ // Which way it called, once it has.
1495
+ ...(spokeFrom.has(t.fp) ? { via: routeTo(spokeFrom.get(t.fp)) ? "tailscale" : "lan" } : {}),
1322
1496
  // AND WHAT IT SAID WHEN IT CALLED — its card, its list, and which
1323
1497
  // of those it is on. The card was kept and never handed over, so
1324
1498
  // the dialog said "it runs an older version" about a deck that
@@ -1332,6 +1506,9 @@ export function createEngine({
1332
1506
  },
1333
1507
  stop() {
1334
1508
  if (timer) clearTimeout(timer);
1509
+ if (tailTimer) clearInterval(tailTimer);
1510
+ tailTimer = null;
1511
+ holder = undefined;
1335
1512
  // A deliberate stop is not a fault, and the next start says its own.
1336
1513
  if (!cfg.enabled) stalled = null;
1337
1514
  timer = null;