ccdeck 3.32.2 → 3.33.1
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 +14 -12
- package/bin/agent-dag.js +6 -0
- package/bin/cli/help.js +2 -0
- package/bin/cli/one-shot.js +32 -13
- package/bin/cli/second-start.js +1 -0
- package/bin/cli/startup.js +34 -0
- package/bin/cli/uninstall.js +47 -2
- package/bin/deck.js +12 -1
- package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
- package/dist/web/assets/index-BIANlQ9A.css +1 -0
- package/dist/web/assets/index-D7ddxIpC.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +36 -1
- package/src/server/account-projects.mjs +11 -2
- package/src/server/args.mjs +12 -2
- package/src/server/auto-update.mjs +14 -1
- package/src/server/best-of-nostalgia.mjs +2 -1
- package/src/server/block-notify.mjs +114 -11
- package/src/server/browser-history.mjs +81 -4
- package/src/server/browser-presence.mjs +98 -2
- package/src/server/browser-react.mjs +39 -4
- package/src/server/browser-watch-seen.mjs +35 -1
- package/src/server/browser-watch.mjs +105 -18
- package/src/server/cafe-music-bgm.mjs +2 -1
- package/src/server/ccusage-install.mjs +8 -4
- package/src/server/ccusage.mjs +3 -1
- package/src/server/claude-accounts.mjs +38 -16
- package/src/server/codex-dir.mjs +8 -6
- package/src/server/codex-quota.mjs +4 -2
- package/src/server/codex-usage.mjs +93 -38
- package/src/server/codex-watch.mjs +150 -18
- package/src/server/cswap-auto-loop.mjs +15 -2
- package/src/server/cswap-auto-readers.mjs +10 -4
- package/src/server/cswap-auto.mjs +6 -6
- package/src/server/cswap-install.mjs +118 -3
- package/src/server/deck-prefs.mjs +4 -0
- package/src/server/event-pipeline.mjs +3 -0
- package/src/server/event-ring.mjs +2 -2
- package/src/server/good-life-radio.mjs +2 -1
- package/src/server/index.mjs +3 -2
- package/src/server/installer.mjs +62 -12
- package/src/server/lan-beacon.mjs +6 -4
- package/src/server/lan-call.mjs +4 -0
- package/src/server/lan-dials.mjs +10 -1
- package/src/server/lan-engine.mjs +221 -44
- package/src/server/lan-requests.mjs +88 -5
- package/src/server/lan-round-record.mjs +21 -4
- package/src/server/lan-socket.mjs +105 -15
- package/src/server/lan-sync.mjs +18 -4
- package/src/server/lifecycle.mjs +21 -2
- package/src/server/live-radio-mix.mjs +2 -1
- package/src/server/log-replay.mjs +55 -24
- package/src/server/login-service.mjs +38 -3
- package/src/server/metrics-run.mjs +5 -3
- package/src/server/network-sampler.mjs +25 -2
- package/src/server/npm-upgrade.mjs +8 -1
- package/src/server/npx.mjs +32 -5
- package/src/server/process-list.mjs +103 -13
- package/src/server/purge-key.mjs +52 -24
- package/src/server/request-gates.mjs +47 -14
- package/src/server/retire-sound-hook.mjs +56 -9
- package/src/server/ring-bounds.mjs +10 -0
- package/src/server/running-deck.mjs +72 -2
- package/src/server/session-enrichment.mjs +40 -8
- package/src/server/stop-deck.mjs +13 -4
- package/src/server/system-metrics.mjs +2 -2
- package/src/server/transcript-scan.mjs +27 -6
- package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
- package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
- package/dist/web/assets/index-DKn7nc15.css +0 -1
- package/dist/web/assets/index-hGO2DTdi.js +0 -299
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
// accounts all work talks to its peers every minute and never asks for
|
|
41
41
|
// anything.
|
|
42
42
|
import {
|
|
43
|
-
addTrusted, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan,
|
|
44
|
-
trustedPeer,
|
|
43
|
+
addTrusted, ANNOUNCE_MS, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, PRESENT_MS,
|
|
44
|
+
stillListed, transferChallenge, trustedPeer,
|
|
45
45
|
} from "./lan-sync.mjs";
|
|
46
46
|
import { mintInvite, readInvite } from "./lan-invite.mjs";
|
|
47
47
|
import { createInviteOffer } from "./lan-invite-offer.mjs";
|
|
@@ -81,6 +81,21 @@ const ROUND_MS = 10_000;
|
|
|
81
81
|
* lan-dials.mjs, which keeps the list and the cap. */
|
|
82
82
|
export { MAX_AUTO_PEERS };
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The most pairings the accept switch may make on its own (#1737).
|
|
86
|
+
*
|
|
87
|
+
* It had no limit, and a pin is a line in prefs.json and a deck every round
|
|
88
|
+
* dials: one host handshaking with a fresh key each time made sixty of them in
|
|
89
|
+
* well under a second. Only pins a SWITCH made count (`auto`, see addTrusted);
|
|
90
|
+
* a person's accepts are theirs, as a person's typed addresses are.
|
|
91
|
+
*
|
|
92
|
+
* THE SAME NUMBER AS MAX_AUTO_PEERS, because every pin the switch makes brings
|
|
93
|
+
* a dial-back row out of that budget, so a pin past it is a deck this one
|
|
94
|
+
* mostly could not dial back anyway. Thirty-two machines is also well past one
|
|
95
|
+
* person's own fleet, which is who the switch is for.
|
|
96
|
+
*/
|
|
97
|
+
export const MAX_AUTO_PINS = MAX_AUTO_PEERS;
|
|
98
|
+
|
|
84
99
|
/** What this machine calls itself when the user has not said. The hostname,
|
|
85
100
|
* because that is the word they already use for this machine everywhere else. */
|
|
86
101
|
export function defaultName() {
|
|
@@ -133,9 +148,14 @@ export { anotherMachine };
|
|
|
133
148
|
* came from the group and the group therefore has it, and a tailnet can reach
|
|
134
149
|
* further than one person's own machines. Sharing there stays a decision
|
|
135
150
|
* somebody makes rather than one an arrival makes for them.
|
|
151
|
+
*
|
|
152
|
+
* AND ONLY FROM A DECK SOMEBODY HERE CHOSE. "The group has it" is true of a
|
|
153
|
+
* group a person put together; a deck the accept switch paired (`pin.auto`,
|
|
154
|
+
* see addTrusted) is one nobody here chose, and what arrives from it is not
|
|
155
|
+
* passed on in this deck's name.
|
|
136
156
|
*/
|
|
137
|
-
export function ticksOnArrival(step, via) {
|
|
138
|
-
return !!step?.key && step.action === "add" && via !== "tailscale";
|
|
157
|
+
export function ticksOnArrival(step, via, pin = null) {
|
|
158
|
+
return !!step?.key && step.action === "add" && via !== "tailscale" && pin?.auto !== true;
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
/** The two permissions that answer for one route — see lan-requests.mjs,
|
|
@@ -373,9 +393,12 @@ export function createEngine({
|
|
|
373
393
|
* or joined on an invite of its own — and take back any earlier unpair of
|
|
374
394
|
* it, because choosing it again is the undo. The list, and whether the pin
|
|
375
395
|
* is new.
|
|
396
|
+
*
|
|
397
|
+
* Or one the accept switch chose for them, which `auto` says and the pin
|
|
398
|
+
* keeps — see addTrusted, and roundWith for what such a deck may not do.
|
|
376
399
|
*/
|
|
377
|
-
const pin = ({ fp, pub, name }) => {
|
|
378
|
-
const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now() });
|
|
400
|
+
const pin = ({ fp, pub, name }, { auto = false } = {}) => {
|
|
401
|
+
const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now(), ...(auto ? { auto: true } : {}) });
|
|
379
402
|
cfg = { ...cfg, trusted: list };
|
|
380
403
|
markUnpaired(fp, false);
|
|
381
404
|
return { list, added };
|
|
@@ -400,12 +423,35 @@ export function createEngine({
|
|
|
400
423
|
about, now, myFp: () => identity.fp, settings: () => cfg,
|
|
401
424
|
});
|
|
402
425
|
|
|
426
|
+
/**
|
|
427
|
+
* WHERE EACH PAIRED DECK LAST ANSWERED: the address and port of the last
|
|
428
|
+
* round whose handshake with it completed against its pin, by fingerprint.
|
|
429
|
+
*
|
|
430
|
+
* A beacon carries a fingerprint and a port, and nothing binds either to the
|
|
431
|
+
* address it came from — so for a paired deck it may say where to try FIRST
|
|
432
|
+
* and nothing more. It used to be the whole answer: the heard row took the
|
|
433
|
+
* beacon's address, was dialled for a day, and a typed row for the same deck
|
|
434
|
+
* was skipped as dialled already. A deck reached by typed address, which is
|
|
435
|
+
* the usual reason to type one, sends no beacon that would ever correct it.
|
|
436
|
+
* This is what a round falls back to when a heard address fails, and what
|
|
437
|
+
* lets a heard address that did answer stay dialled after its beacons stop.
|
|
438
|
+
*/
|
|
439
|
+
const lastGood = new Map();
|
|
440
|
+
const answeredThere = p => {
|
|
441
|
+
const g = lastGood.get(p.fp);
|
|
442
|
+
return !!g && g.addr === p.addr && g.port === p.port;
|
|
443
|
+
};
|
|
444
|
+
/** Whether a round dials this heard row: listed, and either answered at that
|
|
445
|
+
* address or still being announced there. One that never answered is
|
|
446
|
+
* dialled for as long as a deck stays present (PRESENT_MS), not a day. */
|
|
447
|
+
const dialsHeard = p => stillListed(p, now()) && (answeredThere(p) || now() - p.lastSeen < PRESENT_MS);
|
|
448
|
+
|
|
403
449
|
/** Does this deck already hold an address it dials for `fp`? A beacon row it
|
|
404
|
-
* still
|
|
450
|
+
* still dials, or a typed/learned row that answered as that deck. When
|
|
405
451
|
* neither is true, the only way it ever reaches that deck is if the deck
|
|
406
452
|
* keeps calling — and a called deck is never pulled from. */
|
|
407
453
|
const dialsAlready = fp => {
|
|
408
|
-
if (beacon && [...beacon.peers.values()].some(p => p.fp === fp &&
|
|
454
|
+
if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && dialsHeard(p))) return true;
|
|
409
455
|
return dials.answersAs(fp);
|
|
410
456
|
};
|
|
411
457
|
|
|
@@ -491,11 +537,58 @@ export function createEngine({
|
|
|
491
537
|
onChange?.();
|
|
492
538
|
};
|
|
493
539
|
|
|
494
|
-
/**
|
|
495
|
-
*
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
540
|
+
/** The identity a key check is running for, or has already moved away from,
|
|
541
|
+
* and when the last one started — see idClash. */
|
|
542
|
+
let clashFor = null;
|
|
543
|
+
let clashAt = -Infinity;
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Another deck may be using this one's key. When it is, take a new key and
|
|
547
|
+
* keep it. Two decks with one identity are invisible to each other forever
|
|
548
|
+
* otherwise, and the second one to notice moving is enough — whichever
|
|
549
|
+
* notices first, moves.
|
|
550
|
+
*
|
|
551
|
+
* THE BEACON SAYS WHERE TO LOOK, NOT THAT IT IS SO. This deck's fingerprint
|
|
552
|
+
* is in every beacon it sends, so a beacon wearing it is no evidence that
|
|
553
|
+
* anybody else holds the key — and a new key is a stranger to every deck
|
|
554
|
+
* paired with this one, which an invite-only pairing does not recover from
|
|
555
|
+
* without a new invite. What only another holder of the key can do is finish
|
|
556
|
+
* a handshake against this deck's own pin, so the deck dials the address the
|
|
557
|
+
* beacon named and moves only if that handshake completes. Saying it is not
|
|
558
|
+
* asking to pair, so a deck from before its listener answered this is simply
|
|
559
|
+
* not a copy here — and it moves on its own when it hears this deck.
|
|
560
|
+
*
|
|
561
|
+
* NOT ITS OWN LISTENER, which holds the key too and would answer the same
|
|
562
|
+
* way: the challenge on that connection says which one answered.
|
|
563
|
+
*
|
|
564
|
+
* ONE CHECK AT A TIME, AT MOST ONE PER BEACON INTERVAL, AND NONE ONCE IT HAS
|
|
565
|
+
* MOVED. What arrives decides how often this dials, never how much; a real
|
|
566
|
+
* copy announces every ANNOUNCE_MS, so it is looked at by its next beacon at
|
|
567
|
+
* the latest; and after the new key is handed over nothing more is checked
|
|
568
|
+
* until the deck restarts on it.
|
|
569
|
+
*/
|
|
570
|
+
const idClash = async ({ addr, port } = {}) => {
|
|
571
|
+
const mine = identity;
|
|
572
|
+
if (!mine || !server || typeof addr !== "string" || !addr || !port) return;
|
|
573
|
+
if (clashFor === mine || now() - clashAt < ANNOUNCE_MS) return;
|
|
574
|
+
clashFor = mine;
|
|
575
|
+
clashAt = now();
|
|
576
|
+
const startedIn = generation;
|
|
577
|
+
let copy = false;
|
|
578
|
+
let conn = null;
|
|
579
|
+
try {
|
|
580
|
+
conn = await connectToPeer({
|
|
581
|
+
host: addr, port, fp: mine.fp, pub: mine.pub, secret: mine.secret, name: cfg.name,
|
|
582
|
+
myPort: server?.port() ?? null, expectPub: mine.pub, ask: false, sealFrames, ephemeral,
|
|
583
|
+
});
|
|
584
|
+
copy = conn.peerFp === mine.fp && startedIn === generation && !server?.issued(conn.peerChallenge);
|
|
585
|
+
} catch { /* nothing there holds this key */ } finally {
|
|
586
|
+
conn?.sock?.destroy();
|
|
587
|
+
}
|
|
588
|
+
if (!copy || startedIn !== generation || identity !== mine) {
|
|
589
|
+
if (clashFor === mine) clashFor = null;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
499
592
|
const fresh = identityFrom("");
|
|
500
593
|
onIdentity?.(fresh.secret);
|
|
501
594
|
onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
|
|
@@ -514,8 +607,9 @@ export function createEngine({
|
|
|
514
607
|
arrived.forEach((d, i) => { if (typeof found?.[i] === "string") d.why = found[i]; });
|
|
515
608
|
};
|
|
516
609
|
|
|
517
|
-
/** Ask one peer what it has, and heal whatever it can heal.
|
|
518
|
-
|
|
610
|
+
/** Ask one peer what it has, and heal whatever it can heal. `reached` is
|
|
611
|
+
* told the deck's fingerprint once the handshake with it has held. */
|
|
612
|
+
const roundWith = async (peer, reached = null) => {
|
|
519
613
|
let conn = null;
|
|
520
614
|
const startedIn = session;
|
|
521
615
|
// Out here, so a round that dies after some logins arrived still reports
|
|
@@ -539,6 +633,13 @@ export function createEngine({
|
|
|
539
633
|
});
|
|
540
634
|
const ask = frame => askOver(conn, frame);
|
|
541
635
|
|
|
636
|
+
// THIS DECK'S OWN KEY, ANSWERING — its own listener at an address
|
|
637
|
+
// somebody typed, or another machine holding a copy of the key, which a
|
|
638
|
+
// listener now answers (see idClash). Neither is a deck to pin, ask or
|
|
639
|
+
// take anything from, and a pin of this deck's own key would be one
|
|
640
|
+
// every handler here then trusted.
|
|
641
|
+
if (conn.peerFp === identity.fp) throw new Error("that address answers with this deck's own key");
|
|
642
|
+
|
|
542
643
|
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
543
644
|
// and nothing else until somebody answers it — and once one has, the deck
|
|
544
645
|
// on the other end has told us what it calls itself. The row says that
|
|
@@ -599,6 +700,12 @@ export function createEngine({
|
|
|
599
700
|
if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
|
|
600
701
|
}
|
|
601
702
|
|
|
703
|
+
// A PAIRED DECK, ANSWERING HERE against its pin — which is what makes
|
|
704
|
+
// this address one to fall back to, and a heard one worth dialling after
|
|
705
|
+
// its beacons stop. See lastGood.
|
|
706
|
+
lastGood.set(conn.peerFp, { addr: peer.addr, port: peer.port, via: viaOf(peer) });
|
|
707
|
+
reached?.(conn.peerFp);
|
|
708
|
+
|
|
602
709
|
// Our card goes with the question and theirs comes back with the answer
|
|
603
710
|
// — see lan-about.mjs for why it is here and nowhere earlier.
|
|
604
711
|
// THE QUESTION CARRIES THIS DECK'S LIST TOO, and which of it this deck is
|
|
@@ -627,6 +734,14 @@ export function createEngine({
|
|
|
627
734
|
// them, which is the whole of "I do not want to paste blobs any more".
|
|
628
735
|
// What can reach this is what a deck somebody here pressed accept on
|
|
629
736
|
// chose to offer.
|
|
737
|
+
//
|
|
738
|
+
// WHICH IS ONLY TRUE OF A DECK SOMEBODY CHOSE. The accept switch presses
|
|
739
|
+
// accept for the owner, so "somebody here pressed accept" is not a
|
|
740
|
+
// premise about a deck it paired: nobody here chose that deck, and the
|
|
741
|
+
// logins this store holds would be whatever it decided to offer. A deck
|
|
742
|
+
// the switch paired is treated as a heal is — what it offers comes in
|
|
743
|
+
// only for an account ticked here — and a person's own press or invite
|
|
744
|
+
// takes the mark off its pin (see addTrusted).
|
|
630
745
|
// OVER `list`, NOT THE RAW ARRAY. `offered`, which keepManifest runs the
|
|
631
746
|
// list through above (see lan-manifest.mjs), slices to 50 and type-
|
|
632
747
|
// filters `key` and `email`; this line read `theirs.accounts` and got
|
|
@@ -637,13 +752,14 @@ export function createEngine({
|
|
|
637
752
|
// the store lock, while the panel drew 50 and every other paired deck
|
|
638
753
|
// waited behind it. `step.key` also reached transferChallenge and
|
|
639
754
|
// importAccount untyped, which `offered`'s filter would have caught.
|
|
640
|
-
const
|
|
641
|
-
|
|
755
|
+
const chosen = () => trustedPeer(cfg.trusted, conn.peerFp)?.auto !== true;
|
|
756
|
+
const takes = step => cfg.shared.includes(step.key) || (step.action === "add" && chosen());
|
|
757
|
+
const wanted = plan(mine, list).filter(takes);
|
|
642
758
|
// TWO CHECKS, BECAUSE THEY END DIFFERENT THINGS. Losing the session —
|
|
643
759
|
// LAN switched off, the peer unpaired (`stillPaired`, above) — ends the
|
|
644
760
|
// round. A heal unticked mid-round ends only that heal: the adds behind
|
|
645
761
|
// it need no tick, and the skipped row says why rather than vanishing.
|
|
646
|
-
const stillWanted =
|
|
762
|
+
const stillWanted = takes;
|
|
647
763
|
/** One login, asked for and opened: a `want` carrying its own proof that
|
|
648
764
|
* names the account (see transferChallenge), and the `have` opened under
|
|
649
765
|
* the additional data it was sealed with. The login, or why there is
|
|
@@ -705,7 +821,7 @@ export function createEngine({
|
|
|
705
821
|
// The store can finish an import after the owner disabled LAN or
|
|
706
822
|
// revoked this peer. Keep the imported slot, but do not turn it into
|
|
707
823
|
// a newly shared credential on behalf of an obsolete transfer.
|
|
708
|
-
if (ok && stillPaired() && ticksOnArrival(step, viaOf(peer))) {
|
|
824
|
+
if (ok && stillPaired() && ticksOnArrival(step, viaOf(peer), trustedPeer(cfg.trusted, conn.peerFp))) {
|
|
709
825
|
try { await onShared?.(step.key); }
|
|
710
826
|
catch { /* the account is here; the tick is retried the next time one arrives */ }
|
|
711
827
|
}
|
|
@@ -761,40 +877,65 @@ export function createEngine({
|
|
|
761
877
|
if (!beacon) return [];
|
|
762
878
|
const startedIn = session;
|
|
763
879
|
const all = [];
|
|
764
|
-
// Heard first, typed second, and a typed one is skipped when
|
|
765
|
-
// already
|
|
766
|
-
// twice a round and its work counted twice.
|
|
880
|
+
// Heard first, typed second, and a typed one is skipped when this round
|
|
881
|
+
// already dialled that address: otherwise a deck that is both would be
|
|
882
|
+
// dialled twice a round and its work counted twice.
|
|
767
883
|
// The same rule the list uses. A deck that has been silent for a day is not
|
|
768
|
-
// dialled once a minute forever on the chance it comes back
|
|
884
|
+
// dialled once a minute forever on the chance it comes back — and one that
|
|
885
|
+
// never answered at the address it announced, not past PRESENT_MS (see
|
|
886
|
+
// dialsHeard).
|
|
769
887
|
//
|
|
770
888
|
// A deck heard over the tailnet is dialled only while that switch is on, and
|
|
771
889
|
// so is a row the deck added itself from a tailnet beacon. An address a
|
|
772
890
|
// person typed is theirs whatever the switch says — pairing by a typed
|
|
773
891
|
// 100.x address worked before any of this.
|
|
774
892
|
const heard = [...beacon.peers.values()]
|
|
775
|
-
.filter(p =>
|
|
776
|
-
|
|
893
|
+
.filter(p => dialsHeard(p) && (cfg.tailscale || p.via !== "tailscale"));
|
|
894
|
+
/** Every address this round has dialled, and every paired deck it reached
|
|
895
|
+
* by any of them. */
|
|
896
|
+
const tried = new Set();
|
|
897
|
+
const reached = new Set();
|
|
898
|
+
const dial = async peer => {
|
|
899
|
+
tried.add(`${peer.addr}:${peer.port}`);
|
|
900
|
+
all.push(...await roundWith(peer, fp => reached.add(fp)));
|
|
901
|
+
};
|
|
902
|
+
// Sequential rather than parallel. The store takes one mutation at a
|
|
903
|
+
// time anyway (the mutex in store-lock.mjs), and two peers healing the
|
|
904
|
+
// same account at once would race for a slot number claude-swap assigns
|
|
905
|
+
// as max+1 without a lock of its own.
|
|
906
|
+
//
|
|
907
|
+
// And given up when LAN is switched off under it: the rest of the list
|
|
908
|
+
// belongs to no session, and dialling it only delays the next round.
|
|
909
|
+
for (const peer of heard) {
|
|
910
|
+
if (session !== startedIn) return all;
|
|
911
|
+
await dial(peer);
|
|
912
|
+
// A HEARD ADDRESS THAT DID NOT ANSWER, for a deck that answered somewhere
|
|
913
|
+
// else before: that address, in the same round. A beacon says where a
|
|
914
|
+
// deck moved and cannot say it truly — see lastGood. One on the dial list
|
|
915
|
+
// is tried below with the rest of the list; one heard over the tailnet
|
|
916
|
+
// waits for that switch, as its beacon would.
|
|
917
|
+
const back = reached.has(peer.fp) ? null : lastGood.get(peer.fp);
|
|
918
|
+
if (!back || (back.via === "tailscale" && !cfg.tailscale)) continue;
|
|
919
|
+
const at = `${back.addr}:${back.port}`;
|
|
920
|
+
if (tried.has(at) || dials.rows().some(r => `${r.addr}:${r.port}` === at)) continue;
|
|
921
|
+
if (session !== startedIn) return all;
|
|
922
|
+
await dial({ fp: peer.fp, name: peer.name, addr: back.addr, port: back.port, via: back.via });
|
|
923
|
+
}
|
|
777
924
|
// AND ONE DIAL PER DECK, not one per address. A row asked from a tailnet
|
|
778
925
|
// beacon keeps that address after the same deck is heard on the local
|
|
779
926
|
// network, and both used to be dialled every round — two handshakes, and
|
|
780
927
|
// two lines of work for one machine. `dials.metAt` says which deck a row
|
|
781
|
-
// reached;
|
|
782
|
-
|
|
928
|
+
// reached; when this round already reached that deck, by the heard route
|
|
929
|
+
// or any other, the row is skipped. When it did not, the row is the next
|
|
930
|
+
// way to it and is dialled — a heard address alone never stands in for it.
|
|
783
931
|
const typed = dials.rows().filter(p => {
|
|
784
932
|
const at = `${p.addr}:${p.port}`;
|
|
785
|
-
if (
|
|
933
|
+
if (tried.has(at) || reached.has(dials.metAt(at)?.fp)) return false;
|
|
786
934
|
return cfg.tailscale || p.typed || !routeTo(p.addr);
|
|
787
935
|
});
|
|
788
|
-
for (const peer of
|
|
789
|
-
// Sequential rather than parallel. The store takes one mutation at a
|
|
790
|
-
// time anyway (the mutex in store-lock.mjs), and two peers healing the
|
|
791
|
-
// same account at once would race for a slot number claude-swap assigns
|
|
792
|
-
// as max+1 without a lock of its own.
|
|
793
|
-
//
|
|
794
|
-
// And given up when LAN is switched off under it: the rest of the list
|
|
795
|
-
// belongs to no session, and dialling it only delays the next round.
|
|
936
|
+
for (const peer of typed) {
|
|
796
937
|
if (session !== startedIn) return all;
|
|
797
|
-
|
|
938
|
+
await dial(peer);
|
|
798
939
|
}
|
|
799
940
|
if (session !== startedIn) return all;
|
|
800
941
|
lastRound.finished();
|
|
@@ -845,12 +986,16 @@ export function createEngine({
|
|
|
845
986
|
// heard half brings liveness, the dialled half brings the last round.
|
|
846
987
|
const rows = [];
|
|
847
988
|
const byId = new Map();
|
|
989
|
+
/** Of two records of asking one deck, the one written last. */
|
|
990
|
+
const later = (x, y) => (!x ? y : !y ? x : (y.at ?? 0) >= (x.at ?? 0) ? y : x);
|
|
848
991
|
const put = row => {
|
|
849
992
|
const had = byId.get(row.id);
|
|
850
993
|
if (!had) { byId.set(row.id, row); rows.push(row); return; }
|
|
851
|
-
// Keep what each half is the authority on
|
|
994
|
+
// Keep what each half is the authority on — and the LATER ask of the
|
|
995
|
+
// two, which is how the deck is now: a round that could not reach it by
|
|
996
|
+
// its heard address and reached it by the typed one wrote both.
|
|
852
997
|
had.lastSeen = had.lastSeen ?? row.lastSeen;
|
|
853
|
-
had.last = had.last
|
|
998
|
+
had.last = later(had.last, row.last);
|
|
854
999
|
had.manual = had.manual || row.manual;
|
|
855
1000
|
had.met = had.met || row.met;
|
|
856
1001
|
if (row.name && !had.name) had.name = row.name;
|
|
@@ -862,6 +1007,9 @@ export function createEngine({
|
|
|
862
1007
|
const card = id => ({
|
|
863
1008
|
...heardOf(id),
|
|
864
1009
|
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
1010
|
+
// And whether a switch said that yes rather than a person, which is
|
|
1011
|
+
// what the dialog needs to say which logins will not arrive from it.
|
|
1012
|
+
...(trustedPeer(cfg.trusted, id)?.auto ? { autoPaired: true } : {}),
|
|
865
1013
|
});
|
|
866
1014
|
for (const p of [...beacon.peers.values(), ...dials.rows()]) {
|
|
867
1015
|
if (!stillListed(p, now())) continue;
|
|
@@ -976,7 +1124,16 @@ export function createEngine({
|
|
|
976
1124
|
// From here the socket is accepting, so this is the moment the silence
|
|
977
1125
|
// starts being about the network rather than about a deck still starting.
|
|
978
1126
|
inbound.listening();
|
|
979
|
-
|
|
1127
|
+
// AND THE NEXT RESTART PREFERS IT (#1740). The port is the engine's own
|
|
1128
|
+
// field, as the key is: prefs hands it over once per boot (see
|
|
1129
|
+
// lanApplyFields), so without this every later restart in the process
|
|
1130
|
+
// preferred the boot's value — 0 on a first run, which is a fresh random
|
|
1131
|
+
// port for each rename or off-and-on, and the old busy port after one
|
|
1132
|
+
// that moved — and addresses saved on other decks stopped answering.
|
|
1133
|
+
if (port !== cfg.port) {
|
|
1134
|
+
cfg = { ...cfg, port };
|
|
1135
|
+
onPort?.(port);
|
|
1136
|
+
}
|
|
980
1137
|
const startingBeacon = createBeacon({
|
|
981
1138
|
port, name: cfg.name, fp: identity.fp,
|
|
982
1139
|
trusted: () => cfg.trusted,
|
|
@@ -1128,7 +1285,19 @@ export function createEngine({
|
|
|
1128
1285
|
onChange?.();
|
|
1129
1286
|
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
1130
1287
|
}
|
|
1131
|
-
|
|
1288
|
+
// THE SWITCH HAS A BUDGET OF ITS OWN (#1737) — see MAX_AUTO_PINS.
|
|
1289
|
+
//
|
|
1290
|
+
// REFUSED RATHER THAN MAKING ROOM, which is the opposite of the dial
|
|
1291
|
+
// list's choice and for a reason. Evicting a dial row the deck added
|
|
1292
|
+
// undoes nothing anybody chose; evicting a pin is an unpair nobody asked
|
|
1293
|
+
// for, of a deck that may be one of the owner's own machines — and which
|
|
1294
|
+
// pins ever finished a round is known only in memory, so after a restart
|
|
1295
|
+
// there is no telling. Past the cap the switch simply stops pressing,
|
|
1296
|
+
// and the request waits in the panel for a person, as it would with the
|
|
1297
|
+
// switch off.
|
|
1298
|
+
const bySwitch = cfg.trusted.filter(t => t?.auto === true).length;
|
|
1299
|
+
if (!byHand && !trustedPeer(cfg.trusted, fp) && bySwitch >= MAX_AUTO_PINS) return null;
|
|
1300
|
+
const { list, added } = pin({ fp, pub: seen.pub, name: seen.name }, { auto: !byHand });
|
|
1132
1301
|
requests.drop(fp);
|
|
1133
1302
|
onTrust?.(list);
|
|
1134
1303
|
onChange?.();
|
|
@@ -1140,9 +1309,16 @@ export function createEngine({
|
|
|
1140
1309
|
// port it listens on for exactly this. AND KEPT, through onDial, as an
|
|
1141
1310
|
// invite's is (#1643): `addPeer` alone lives in memory, and the next
|
|
1142
1311
|
// settings write or restart made the pairing one-way again.
|
|
1143
|
-
|
|
1312
|
+
//
|
|
1313
|
+
// ONLY A PERSON'S ACCEPT IS KEPT, OR COUNTED AS TYPED (#1737). The row
|
|
1314
|
+
// the switch leaves is one the deck added itself, the way a beacon's is:
|
|
1315
|
+
// capped by MAX_AUTO_PEERS, in memory only, and never written into prefs
|
|
1316
|
+
// as an address somebody named — the port in a hello is whatever the
|
|
1317
|
+
// caller claimed. After a restart such a deck is reached as any paired
|
|
1318
|
+
// deck is: by its beacon, or by calling in (see learnCaller).
|
|
1319
|
+
const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port, { typed: byHand })
|
|
1144
1320
|
? (dials.meet(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
|
|
1145
|
-
onDial?.(`${seen.addr}:${seen.port}`),
|
|
1321
|
+
byHand && onDial?.(`${seen.addr}:${seen.port}`),
|
|
1146
1322
|
{ addr: seen.addr, port: seen.port })
|
|
1147
1323
|
: null;
|
|
1148
1324
|
return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
|
|
@@ -1157,6 +1333,7 @@ export function createEngine({
|
|
|
1157
1333
|
const list = dropTrusted(cfg.trusted, fp);
|
|
1158
1334
|
if (list.length === cfg.trusted.length) return false;
|
|
1159
1335
|
cfg = { ...cfg, trusted: list };
|
|
1336
|
+
lastGood.delete(fp);
|
|
1160
1337
|
markUnpaired(fp, true);
|
|
1161
1338
|
onTrust?.(list);
|
|
1162
1339
|
onChange?.();
|
|
@@ -1207,7 +1384,7 @@ export function createEngine({
|
|
|
1207
1384
|
*/
|
|
1208
1385
|
async roundOne(fp) {
|
|
1209
1386
|
if (!beacon || typeof fp !== "string" || !fp) return null;
|
|
1210
|
-
const heard = [...beacon.peers.values()].find(p => p.fp === fp &&
|
|
1387
|
+
const heard = [...beacon.peers.values()].find(p => p.fp === fp && dialsHeard(p));
|
|
1211
1388
|
const typed = dials.rowAnswering(fp);
|
|
1212
1389
|
const peer = heard ?? typed;
|
|
1213
1390
|
if (!peer) return null;
|
|
@@ -5,7 +5,44 @@
|
|
|
5
5
|
// key, or putting an address on the dial list — stays the engine's accept;
|
|
6
6
|
// this decides who is on which list and when that accept is pressed.
|
|
7
7
|
|
|
8
|
-
import { pairable } from "./lan-sync.mjs";
|
|
8
|
+
import { pairable, PRESENT_MS } from "./lan-sync.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The most decks the heard list keeps at once (#1738).
|
|
12
|
+
*
|
|
13
|
+
* A beacon is one UDP packet, its source address can be anything, and every
|
|
14
|
+
* new fingerprint used to be a row kept for as long as the process lived: 52 MB
|
|
15
|
+
* of heap for 200,000 forged ones, and every panel poll sorting all of them.
|
|
16
|
+
* The panel draws eight (see pairable) and collapses one machine's keys into
|
|
17
|
+
* one row, so a few hundred is room for an office of decks each restarted a
|
|
18
|
+
* few times inside the presence window, and small enough that the sort every
|
|
19
|
+
* poll does is nothing. The deck heard longest ago goes first; a real one is
|
|
20
|
+
* heard again within thirty seconds and comes straight back.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_STRANGERS = 256;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The most requests kept at once (#1738).
|
|
26
|
+
*
|
|
27
|
+
* Each is a row in the panel with an accept on it, and all of them are drawn:
|
|
28
|
+
* one host handshaking with fresh keys put two hundred there in a second. The
|
|
29
|
+
* same thirty-two the accept switch may pin (MAX_AUTO_PINS in lan-engine.mjs),
|
|
30
|
+
* which is well past one person's own machines and still a list somebody can
|
|
31
|
+
* read. The request asked longest ago goes first — a deck that is really
|
|
32
|
+
* waiting asks again every round, so it is never the one that has.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_PENDING = 32;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How long a request stands after its deck last asked (#1738).
|
|
38
|
+
*
|
|
39
|
+
* A deck waiting on an answer dials again every round — every few seconds while
|
|
40
|
+
* it waits (ASKING_MS), once a minute at the slowest (SYNC_MS) — and each dial
|
|
41
|
+
* that finds no pin here asks again. Ten minutes is ten of its slowest rounds
|
|
42
|
+
* without a word, which is a deck that stopped asking; if it starts again it
|
|
43
|
+
* is a request again on its next dial.
|
|
44
|
+
*/
|
|
45
|
+
export const REQUEST_MS = 10 * 60_000;
|
|
9
46
|
|
|
10
47
|
/**
|
|
11
48
|
* The two permissions that answer for one route.
|
|
@@ -62,6 +99,26 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
62
99
|
*/
|
|
63
100
|
const declined = new Map();
|
|
64
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Take off both lists what has stopped being true (#1738): a heard deck quiet
|
|
104
|
+
* for longer than presence lasts — the window strangerRows already drew by,
|
|
105
|
+
* so this forgets only what nobody could see — and a request whose deck has
|
|
106
|
+
* not asked for REQUEST_MS. Run before each list is written or read, so what
|
|
107
|
+
* is kept and what is shown agree.
|
|
108
|
+
*
|
|
109
|
+
* The heard list is kept in the order decks were last heard (heardStranger
|
|
110
|
+
* moves a deck to the end each time), so its quiet ones are at the front and
|
|
111
|
+
* this stops at the first that is not. The requests are few enough to walk.
|
|
112
|
+
*/
|
|
113
|
+
const forget = () => {
|
|
114
|
+
const t = now();
|
|
115
|
+
for (const [fp, p] of strangers) {
|
|
116
|
+
if (t - (p.at ?? 0) <= PRESENT_MS) break;
|
|
117
|
+
strangers.delete(fp);
|
|
118
|
+
}
|
|
119
|
+
for (const [fp, p] of pending) if (t - (p.lastAt ?? p.at ?? 0) > REQUEST_MS) pending.delete(fp);
|
|
120
|
+
};
|
|
121
|
+
|
|
65
122
|
/**
|
|
66
123
|
* A real deck nobody here has accepted: draw it as a row with an accept on it.
|
|
67
124
|
*
|
|
@@ -85,7 +142,17 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
85
142
|
const askToAccept = entry => {
|
|
86
143
|
const cfg = settings();
|
|
87
144
|
if (cfg.pairingMode === "invite" || declined.has(entry.fp)) return;
|
|
145
|
+
forget();
|
|
88
146
|
const had = pending.get(entry.fp);
|
|
147
|
+
// A NEW ONE PAST THE CAP MAKES ROOM (#1738) — see MAX_PENDING. The one
|
|
148
|
+
// asked longest ago goes, not the newcomer: refusing new ones would let
|
|
149
|
+
// whoever filled the list first keep it, and a real deck asking later
|
|
150
|
+
// would never be drawn.
|
|
151
|
+
if (!had && pending.size >= MAX_PENDING) {
|
|
152
|
+
let oldest = null;
|
|
153
|
+
for (const [fp, p] of pending) if (!oldest || p.lastAt < oldest[1].lastAt) oldest = [fp, p];
|
|
154
|
+
if (oldest) pending.delete(oldest[0]);
|
|
155
|
+
}
|
|
89
156
|
// WHICH SWITCH ANSWERS depends on where the deck is. A request from the
|
|
90
157
|
// tailnet is answered by the Tailscale pair, and only for a machine on this
|
|
91
158
|
// person's own Tailscale account; anything else on the tailnet waits for a
|
|
@@ -94,9 +161,11 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
94
161
|
const via = route ? "tailscale" : "lan";
|
|
95
162
|
const own = !!route?.own;
|
|
96
163
|
pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
|
|
164
|
+
// An accept the switch pressed that did not pin — the switch's own budget
|
|
165
|
+
// is spent, see MAX_AUTO_PINS in lan-engine.mjs — leaves the request as it
|
|
166
|
+
// would be with the switch off: a row, drawn, for somebody to answer.
|
|
97
167
|
if (!wasUnpaired(entry.fp) && saysYesOn(cfg, via) && (via === "lan" || own)) {
|
|
98
|
-
engineNow()?.accept(entry.fp, { byHand: false });
|
|
99
|
-
return;
|
|
168
|
+
if (engineNow()?.accept(entry.fp, { byHand: false })) return;
|
|
100
169
|
}
|
|
101
170
|
if (!had) onChange?.();
|
|
102
171
|
};
|
|
@@ -105,6 +174,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
105
174
|
* can accept, and — when the ask switch for its route is on — asked. */
|
|
106
175
|
const heardStranger = entry => {
|
|
107
176
|
const cfg = settings();
|
|
177
|
+
forget();
|
|
108
178
|
const had = strangers.get(entry.fp);
|
|
109
179
|
// KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
|
|
110
180
|
// a fresh key — a second deck sharing one config directory does, by
|
|
@@ -112,7 +182,16 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
112
182
|
// one of them drew a row offering to pair with the same machine.
|
|
113
183
|
if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
|
|
114
184
|
const own = entry.via === "tailscale" && !!routeTo(entry.addr)?.own;
|
|
185
|
+
// To the END, so the list stays in the order decks were last heard — which
|
|
186
|
+
// is what lets `forget` stop at the first one still present, and what
|
|
187
|
+
// makes the first one here the one to drop when the list is full.
|
|
188
|
+
strangers.delete(entry.fp);
|
|
115
189
|
strangers.set(entry.fp, { ...entry, own });
|
|
190
|
+
// AND NEVER PAST ITS SIZE (#1738) — see MAX_STRANGERS.
|
|
191
|
+
for (const fp of strangers.keys()) {
|
|
192
|
+
if (strangers.size <= MAX_STRANGERS) break;
|
|
193
|
+
strangers.delete(fp);
|
|
194
|
+
}
|
|
116
195
|
// ASK IT, which is what the `ask` verb on its row does and nothing
|
|
117
196
|
// more: the address goes on the dial list and the next round sends a
|
|
118
197
|
// request that somebody over there still has to answer. A beacon
|
|
@@ -171,6 +250,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
171
250
|
|
|
172
251
|
/** The heard decks status() offers to pair with — see its `strangers`. */
|
|
173
252
|
const strangerRows = () => {
|
|
253
|
+
forget();
|
|
174
254
|
// A deck that was told no is not somebody to offer pairing with. It
|
|
175
255
|
// has its own row, with the one control that undoes the decision.
|
|
176
256
|
const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
|
|
@@ -193,7 +273,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
193
273
|
isDeclined: fp => declined.has(fp),
|
|
194
274
|
/** The row a deck is waiting on: its request when it asked, its beacon
|
|
195
275
|
* when it was only heard, or null. */
|
|
196
|
-
seen: fp => (pending.get(fp) ?? null) ?? (strangers.get(fp) ?? null),
|
|
276
|
+
seen: fp => { forget(); return (pending.get(fp) ?? null) ?? (strangers.get(fp) ?? null); },
|
|
197
277
|
/** It was answered: off both lists. */
|
|
198
278
|
drop(fp) {
|
|
199
279
|
pending.delete(fp);
|
|
@@ -242,7 +322,10 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
|
|
|
242
322
|
return had;
|
|
243
323
|
},
|
|
244
324
|
/** The rows status() draws for each list. */
|
|
245
|
-
pendingRows: () =>
|
|
325
|
+
pendingRows: () => {
|
|
326
|
+
forget();
|
|
327
|
+
return [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at, via: p.via ?? "lan", own: !!p.own }));
|
|
328
|
+
},
|
|
246
329
|
strangerRows,
|
|
247
330
|
declinedRows: () => [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
|
|
248
331
|
};
|
|
@@ -15,6 +15,11 @@ export function createRoundRecord({ now }) {
|
|
|
15
15
|
* pressed it wants to know it happened, and a reader who did not wants to
|
|
16
16
|
* know the list is not a photograph of an hour ago. */
|
|
17
17
|
let roundAt = null;
|
|
18
|
+
/** The rows the last whole round asked, and the rows asked since it — the
|
|
19
|
+
* only ones whose line still says anything about who is deciding. See
|
|
20
|
+
* waitingOnSomebody. */
|
|
21
|
+
let asked = new Set();
|
|
22
|
+
let asking = new Set();
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
25
|
* Is anybody on the other end still deciding?
|
|
@@ -22,9 +27,17 @@ export function createRoundRecord({ now }) {
|
|
|
22
27
|
* The exact sentence lan-call.mjs gives for "a real deck, not yet
|
|
23
28
|
* accepted", which is the one state where dialling again in a few seconds
|
|
24
29
|
* does something a minute later would not.
|
|
30
|
+
*
|
|
31
|
+
* AND ONLY OF A ROW A ROUND STILL ASKS (#1742). A line stays here after its
|
|
32
|
+
* row is gone — removed in the panel, dropped by a settings write, evicted
|
|
33
|
+
* by the cap, or no longer dialled at all — and nothing asks that row again
|
|
34
|
+
* to overwrite it, so one that said "waiting" kept every round eight seconds
|
|
35
|
+
* apart until the deck restarted. What the last whole round asked is the
|
|
36
|
+
* list the next one dials, give or take the row somebody just changed; a
|
|
37
|
+
* row taken off costs one more quick round, never the rest of the process.
|
|
25
38
|
*/
|
|
26
39
|
const waitingOnSomebody = () =>
|
|
27
|
-
[...
|
|
40
|
+
[...asked, ...asking].some(fp => lastRound.get(fp)?.error === "waiting for the other deck to accept this one");
|
|
28
41
|
|
|
29
42
|
return {
|
|
30
43
|
/** What the last ask of a row did, or undefined. Keyed by the ROW the
|
|
@@ -34,11 +47,15 @@ export function createRoundRecord({ now }) {
|
|
|
34
47
|
/** An ask of that row finished, well or not, and this is what it did. The
|
|
35
48
|
* record is kept as it is handed over, so the same object before and
|
|
36
49
|
* after a wait means nothing asked that row in between — see roundOne. */
|
|
37
|
-
keep(fp, record) { lastRound.set(fp, record); },
|
|
50
|
+
keep(fp, record) { lastRound.set(fp, record); asking.add(fp); },
|
|
38
51
|
/** Nothing of that row is drawn any more: a dial-back taken away. */
|
|
39
52
|
drop(fp) { lastRound.delete(fp); },
|
|
40
|
-
/** A whole round finished — see roundAt. */
|
|
41
|
-
finished() {
|
|
53
|
+
/** A whole round finished — see roundAt, and asked. */
|
|
54
|
+
finished() {
|
|
55
|
+
roundAt = now();
|
|
56
|
+
asked = asking;
|
|
57
|
+
asking = new Set();
|
|
58
|
+
},
|
|
42
59
|
checkedAt: () => roundAt,
|
|
43
60
|
waitingOnSomebody,
|
|
44
61
|
};
|