ccdeck 3.28.1 → 3.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/THIRD_PARTY_NOTICES.md +148 -4
- package/dist/web/assets/{BrowserWatchModal-CIla-_hm.js → BrowserWatchModal-CxfWHmjb.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CSgh5kjS.js → UsageHistoryModal-D-XRttte.js} +1 -1
- package/dist/web/assets/hls-BFpXDghs.js +42 -0
- package/dist/web/assets/index-DrbRdXDD.js +298 -0
- package/dist/web/assets/index-GTZ73XfY.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/release-notes.json +26 -0
- package/src/server/account-health.mjs +38 -0
- package/src/server/claude-accounts.mjs +84 -26
- package/src/server/cswap-admin.mjs +136 -8
- package/src/server/deck-prefs.mjs +2 -0
- package/src/server/fm-station.mjs +186 -0
- package/src/server/index.mjs +204 -20
- package/src/server/lan-engine.mjs +244 -38
- package/src/server/lan-socket.mjs +101 -15
- package/src/server/lan-sync.mjs +86 -9
- package/dist/web/assets/index-CsHTYwoD.js +0 -297
- package/dist/web/assets/index-DbwzUUcd.css +0 -1
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
// something in it, which is when an account is actually broken. A deck whose
|
|
28
28
|
// accounts all work talks to its peers every minute and never asks for
|
|
29
29
|
// anything.
|
|
30
|
-
import { accountKey, currentFor, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
30
|
+
import { accountKey, currentFor, manifestFor, onePerKey, open, peerWhy, plan, seal, SENDER_UNREADABLE, slotFor, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
31
|
+
import { storedCopyAlive, cachedExportReadable, liveLoginIs } from "./account-health.mjs";
|
|
31
32
|
import { connectToPeer, createBeacon, createSyncServer, DISCOVERY_PORT, MAX_FRAME_BYTES } from "./lan-socket.mjs";
|
|
32
33
|
import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
|
|
33
34
|
import { openAbout, sealAbout } from "./lan-about.mjs";
|
|
@@ -156,9 +157,8 @@ function flatten(v, max) {
|
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
export function offered(list) {
|
|
159
|
-
return (Array.isArray(list) ? list : [])
|
|
160
|
+
return onePerKey((Array.isArray(list) ? list : [])
|
|
160
161
|
.filter(a => a && typeof a.key === "string" && typeof a.email === "string")
|
|
161
|
-
.slice(0, 50)
|
|
162
162
|
// Character-filtered, not merely cut. These two are drawn beside the
|
|
163
163
|
// fingerprint at the moment the operator picks which of a peer's logins to
|
|
164
164
|
// import (LanPeerModal.tsx:497), and a bare slice let a format character
|
|
@@ -166,9 +166,16 @@ export function offered(list) {
|
|
|
166
166
|
.map(a => ({
|
|
167
167
|
key: flatten(a.key, 320),
|
|
168
168
|
email: flatten(a.email, 254),
|
|
169
|
-
alive: a.alive
|
|
169
|
+
alive: storedCopyAlive(a.alive, a.collector),
|
|
170
|
+
// Additive wire field: an older peer omitted it, which means shareable.
|
|
171
|
+
...(a.shareable === false ? { shareable: false } : {}),
|
|
170
172
|
}))
|
|
171
|
-
.filter(a => a.key && a.email)
|
|
173
|
+
.filter(a => a.key && a.email))
|
|
174
|
+
// Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
|
|
175
|
+
// it: a peer with duplicate slots could otherwise push a live copy past
|
|
176
|
+
// row fifty and out of the list while offering far fewer than fifty
|
|
177
|
+
// logins. The raw array is already bounded by MAX_FRAME_BYTES.
|
|
178
|
+
.slice(0, 50);
|
|
172
179
|
}
|
|
173
180
|
|
|
174
181
|
/**
|
|
@@ -212,7 +219,7 @@ export function ticksOnArrival(step, via) {
|
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
export function createEngine({
|
|
215
|
-
readAccounts, exportAccount, importAccount,
|
|
222
|
+
readAccounts, exportAccount, importAccount, checkArrivals, liveLogin,
|
|
216
223
|
onChange, onError, onIdentity, onPort, onTrust, onUnpaired, onDial, onShared, now = Date.now,
|
|
217
224
|
/**
|
|
218
225
|
* The UDP socket the beacon shouts through, injectable for the same reason
|
|
@@ -263,7 +270,7 @@ export function createEngine({
|
|
|
263
270
|
} = {}) {
|
|
264
271
|
let cfg = {
|
|
265
272
|
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], unpaired: [], port: 0,
|
|
266
|
-
autoAsk: true, autoAccept: true, aliases: {},
|
|
273
|
+
autoAsk: true, autoAccept: true, pairingMode: "automatic", aliases: {},
|
|
267
274
|
// Tell paired decks which shared account this one is on — see currentFor.
|
|
268
275
|
shareActive: true,
|
|
269
276
|
// DISCOVERY OVER TAILSCALE, off until somebody turns it on, and its own
|
|
@@ -273,6 +280,14 @@ export function createEngine({
|
|
|
273
280
|
// person's own Tailscale account — see routeOf.
|
|
274
281
|
tailscale: false, tailscaleAsk: true, tailscaleAccept: true,
|
|
275
282
|
};
|
|
283
|
+
// Bumped by every stop, a restart included: anything started before it
|
|
284
|
+
// belongs to a listener that is gone.
|
|
285
|
+
let generation = 0;
|
|
286
|
+
// What a round checks instead, so a round stopped by switching LAN off
|
|
287
|
+
// cannot resume when it is switched back on. A restart for a new name or
|
|
288
|
+
// key revokes nothing — the peer is still paired and LAN still on — so a
|
|
289
|
+
// transfer in flight lands and is shared onward as usual.
|
|
290
|
+
let session = 0;
|
|
276
291
|
let identity = null;
|
|
277
292
|
let beacon = null;
|
|
278
293
|
let server = null;
|
|
@@ -405,6 +420,8 @@ export function createEngine({
|
|
|
405
420
|
|
|
406
421
|
/** The tailnet read's own timer, running only while the switch is on. */
|
|
407
422
|
let tailTimer = null;
|
|
423
|
+
// An in-flight refresh may finish after discovery is disabled or restarted.
|
|
424
|
+
let tailRefreshGeneration = 0;
|
|
408
425
|
|
|
409
426
|
/** Who held the discovery port the last time it was asked, for as long as
|
|
410
427
|
* this deck cannot hear: the answer does not change between two tries half a
|
|
@@ -443,8 +460,8 @@ export function createEngine({
|
|
|
443
460
|
};
|
|
444
461
|
|
|
445
462
|
/** The two permissions that answer for one route. */
|
|
446
|
-
const asksOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
|
|
447
|
-
const saysYesOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
|
|
463
|
+
const asksOn = via => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
|
|
464
|
+
const saysYesOn = via => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
|
|
448
465
|
|
|
449
466
|
/**
|
|
450
467
|
* Read the tailnet on a timer while the switch is on, and not at all while it
|
|
@@ -458,10 +475,17 @@ export function createEngine({
|
|
|
458
475
|
const syncTailnet = () => {
|
|
459
476
|
const want = !!(beacon && tailnet && cfg.enabled && cfg.tailscale);
|
|
460
477
|
if (want && !tailTimer) {
|
|
461
|
-
|
|
478
|
+
const startedIn = ++tailRefreshGeneration;
|
|
479
|
+
const currentBeacon = beacon;
|
|
480
|
+
void tailnet.refresh().then(() => {
|
|
481
|
+
if (startedIn === tailRefreshGeneration && beacon === currentBeacon && cfg.enabled && cfg.tailscale) {
|
|
482
|
+
currentBeacon.announce();
|
|
483
|
+
}
|
|
484
|
+
}, () => {});
|
|
462
485
|
tailTimer = setInterval(() => { void tailnet.refresh(); }, TAILNET_MS);
|
|
463
486
|
tailTimer.unref?.();
|
|
464
487
|
} else if (!want && tailTimer) {
|
|
488
|
+
tailRefreshGeneration++;
|
|
465
489
|
clearInterval(tailTimer);
|
|
466
490
|
tailTimer = null;
|
|
467
491
|
}
|
|
@@ -474,7 +498,14 @@ export function createEngine({
|
|
|
474
498
|
return (got?.accounts ?? []).map(a => ({
|
|
475
499
|
key: accountKey(a.email, a.orgUuid),
|
|
476
500
|
email: a.email,
|
|
477
|
-
|
|
501
|
+
org: a.orgUuid,
|
|
502
|
+
alive: storedCopyAlive(a.alive, a.collector),
|
|
503
|
+
// False only for a login the wiring knows this process cannot read (a
|
|
504
|
+
// Mac whose Keychain will not open from here). Absent means readable,
|
|
505
|
+
// which is every deck that does not say.
|
|
506
|
+
readable: a.readable !== false && cachedExportReadable(a.collector, { active: a.active === true }),
|
|
507
|
+
unreadableWhy: a.readable === false || a.collector === "keychain_unavailable"
|
|
508
|
+
? SENDER_UNREADABLE : "export failed",
|
|
478
509
|
num: a.num,
|
|
479
510
|
// The one this deck is on — claude-swap's own answer, one at most.
|
|
480
511
|
active: a.active === true,
|
|
@@ -542,6 +573,10 @@ export function createEngine({
|
|
|
542
573
|
};
|
|
543
574
|
|
|
544
575
|
const serve = async (msg, ctx) => {
|
|
576
|
+
// Authentication happened at connection setup; a previously trusted deck
|
|
577
|
+
// may have been unpaired while this socket remained open.
|
|
578
|
+
const mayAnswer = () => cfg.enabled && !!server && !!trustedPeer(cfg.trusted, ctx?.peerFp);
|
|
579
|
+
if (!mayAnswer()) return ctx.send({ t: "no", why: "not paired" });
|
|
545
580
|
// Before the verbs, and for every one of them: something that proved it
|
|
546
581
|
// holds a key this deck accepted is talking, now.
|
|
547
582
|
if (ctx?.peerFp) {
|
|
@@ -567,6 +602,10 @@ export function createEngine({
|
|
|
567
602
|
offersBy.set(ctx.peerFp, { at: now(), accounts: list, current: heardCurrent(msg.current, list) });
|
|
568
603
|
}
|
|
569
604
|
const accounts = await localAccounts();
|
|
605
|
+
// Reading the store can take long enough for the owner to unpair this
|
|
606
|
+
// deck. Do not disclose account identities or the active account from
|
|
607
|
+
// a manifest assembled before that decision.
|
|
608
|
+
if (!mayAnswer()) return ctx.send({ t: "no", why: "not paired" });
|
|
570
609
|
return ctx.send({
|
|
571
610
|
t: "manifest", accounts: manifestFor(accounts, cfg.shared),
|
|
572
611
|
...currentFor(accounts, cfg.shared, cfg.shareActive),
|
|
@@ -574,6 +613,12 @@ export function createEngine({
|
|
|
574
613
|
});
|
|
575
614
|
}
|
|
576
615
|
if (msg.t === "want") {
|
|
616
|
+
// A listener may have authenticated this socket before its owner
|
|
617
|
+
// unpaired the caller or switched sharing off. Recheck at the moment
|
|
618
|
+
// a credential is requested and again after every asynchronous read.
|
|
619
|
+
const maySend = () => cfg.enabled && !!server && !!trustedPeer(cfg.trusted, ctx.peerFp)
|
|
620
|
+
&& cfg.shared.includes(msg.key);
|
|
621
|
+
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
577
622
|
// A SECOND PROOF, for the one operation that moves a credential. The
|
|
578
623
|
// session says who connected; this says they are asking for this
|
|
579
624
|
// account, now. A long-lived connection authenticated an hour ago is
|
|
@@ -587,11 +632,30 @@ export function createEngine({
|
|
|
587
632
|
// Only what the user ticked, checked again here rather than trusted
|
|
588
633
|
// from the manifest we sent: the list can change between the two, and
|
|
589
634
|
// the answer that matters is the one at the moment of sending.
|
|
590
|
-
if (!cfg.shared.includes(msg.key)) return ctx.send({ t: "no", why: "not shared" });
|
|
591
635
|
const accounts = await localAccounts();
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
636
|
+
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
637
|
+
// The slot manifestFor told the peer about, by the same rule: with two
|
|
638
|
+
// slots for one identity, an expired one listed first must not hide a
|
|
639
|
+
// live one behind it.
|
|
640
|
+
const mine = slotFor(accounts, msg.key);
|
|
641
|
+
if (!mine) return ctx.send({ t: "no", why: "not mine to give" });
|
|
642
|
+
// A LOGIN THIS DECK CANNOT READ IS SAID SO, from state rather than from
|
|
643
|
+
// a failed export: no subprocess, and nothing the CLI printed. The
|
|
644
|
+
// asking deck prints it under the account, so the person learns which
|
|
645
|
+
// machine to unlock instead of reading "export failed".
|
|
646
|
+
if (!mine.readable) return ctx.send({ t: "no", why: mine.unreadableWhy });
|
|
647
|
+
if (!mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
|
|
648
|
+
// The active slot exports the live CLI login, and its verdict can
|
|
649
|
+
// predate a `/login` as somebody else, so ask the CLI who it is now.
|
|
650
|
+
if (mine.active && liveLogin && !liveLoginIs(await liveLogin(), mine.email, mine.org)) {
|
|
651
|
+
return ctx.send({ t: "no", why: "export failed" });
|
|
652
|
+
}
|
|
653
|
+
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
654
|
+
const blob = await exportAccount(mine.num, msg.key);
|
|
655
|
+
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
656
|
+
// A Mac's failed export refreshes the verdict behind `readable` in the
|
|
657
|
+
// background, so when the Keychain was why, the next ask is answered by
|
|
658
|
+
// the line above.
|
|
595
659
|
if (!blob) return ctx.send({ t: "no", why: "export failed" });
|
|
596
660
|
const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
|
|
597
661
|
return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
|
|
@@ -623,7 +687,7 @@ export function createEngine({
|
|
|
623
687
|
* is nothing before this, so the check lives here.
|
|
624
688
|
*/
|
|
625
689
|
const askToAccept = entry => {
|
|
626
|
-
if (declined.has(entry.fp)) return;
|
|
690
|
+
if (cfg.pairingMode === "invite" || declined.has(entry.fp)) return;
|
|
627
691
|
const had = pending.get(entry.fp);
|
|
628
692
|
// WHICH SWITCH ANSWERS depends on where the deck is. A request from the
|
|
629
693
|
// tailnet is answered by the Tailscale pair, and only for a machine on this
|
|
@@ -654,9 +718,23 @@ export function createEngine({
|
|
|
654
718
|
* read from the routing table, the way the peer list reads it. */
|
|
655
719
|
const viaOf = peer => peer.via ?? (routeTo(peer.addr) ? "tailscale" : "lan");
|
|
656
720
|
|
|
721
|
+
/** Checks the logins a round brought, once, and records any reason one is
|
|
722
|
+
* unusable here on its row. An unanswered check is not a failure. */
|
|
723
|
+
const markArrivals = async done => {
|
|
724
|
+
const arrived = done.filter(d => d.ok);
|
|
725
|
+
if (!arrived.length || !checkArrivals) return;
|
|
726
|
+
let found = null;
|
|
727
|
+
try { found = await checkArrivals(arrived); } catch { /* unasked is not a failure of the round */ }
|
|
728
|
+
arrived.forEach((d, i) => { if (typeof found?.[i] === "string") d.why = found[i]; });
|
|
729
|
+
};
|
|
730
|
+
|
|
657
731
|
/** Ask one peer what it has, and heal whatever it can heal. */
|
|
658
732
|
const roundWith = async peer => {
|
|
659
733
|
let conn = null;
|
|
734
|
+
const startedIn = session;
|
|
735
|
+
// Out here, so a round that dies after some logins arrived still reports
|
|
736
|
+
// them — see the catch below.
|
|
737
|
+
const done = [];
|
|
660
738
|
try {
|
|
661
739
|
conn = await connectToPeer({
|
|
662
740
|
host: peer.addr, port: peer.port, timeoutMs: ROUND_MS,
|
|
@@ -667,6 +745,10 @@ export function createEngine({
|
|
|
667
745
|
// The key pinned when this deck was accepted, so a second machine
|
|
668
746
|
// answering at that address is refused rather than talked to.
|
|
669
747
|
expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
|
|
748
|
+
// Invite-only still dials the rows it has — an invite-paired deck is one
|
|
749
|
+
// of them — but tells the far end it is not asking, so a row that turns
|
|
750
|
+
// out to be a stranger is refused there instead of becoming a request.
|
|
751
|
+
ask: cfg.pairingMode !== "invite",
|
|
670
752
|
sealFrames, ephemeral,
|
|
671
753
|
});
|
|
672
754
|
// A SECOND READER ON THE SAME SOCKET, AND IT HAS TO KEEP THE SAME CAP.
|
|
@@ -683,14 +765,22 @@ export function createEngine({
|
|
|
683
765
|
// reject path also never removed the listener; only roundWith's
|
|
684
766
|
// `finally { conn?.sock?.destroy(); }` stopped it.
|
|
685
767
|
const ask = frame => new Promise((resolve, reject) => {
|
|
686
|
-
const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
|
|
687
|
-
bell.unref?.();
|
|
688
768
|
let buf = "";
|
|
769
|
+
let settled = false;
|
|
689
770
|
const give = (fn, arg) => {
|
|
771
|
+
if (settled) return;
|
|
772
|
+
settled = true;
|
|
690
773
|
clearTimeout(bell);
|
|
691
774
|
conn.sock.off("data", onData);
|
|
775
|
+
conn.sock.off("close", onClose);
|
|
776
|
+
conn.sock.off("error", onError);
|
|
692
777
|
fn(arg);
|
|
693
778
|
};
|
|
779
|
+
const onClose = () => give(reject, new Error("peer closed the connection"));
|
|
780
|
+
// The errno rides along in the sentence, because faultText reads it
|
|
781
|
+
// there: ECONNRESET is "it hung up" on the row, and a bare "peer
|
|
782
|
+
// connection failed" was the one fault the panel could only repeat.
|
|
783
|
+
const onError = err => give(reject, new Error(`peer connection failed${err?.code ? ` (${err.code})` : ""}`));
|
|
694
784
|
const onData = chunk => {
|
|
695
785
|
buf += chunk;
|
|
696
786
|
if (buf.length > MAX_FRAME_BYTES) {
|
|
@@ -714,9 +804,18 @@ export function createEngine({
|
|
|
714
804
|
}
|
|
715
805
|
give(resolve, got);
|
|
716
806
|
};
|
|
807
|
+
const bell = setTimeout(() => give(reject, new Error("peer went quiet")), ROUND_MS);
|
|
808
|
+
bell.unref?.();
|
|
717
809
|
conn.sock.on("data", onData);
|
|
810
|
+
conn.sock.on("close", onClose);
|
|
811
|
+
conn.sock.on("error", onError);
|
|
718
812
|
// And out through its own writer, which seals whenever the reader opens.
|
|
719
|
-
|
|
813
|
+
// A dead socket cannot throw here — sendFrame swallows a failed write,
|
|
814
|
+
// and `close`/`error` above are what report it. What CAN throw is the
|
|
815
|
+
// seal running out of counter, which is this deck's limit and not a
|
|
816
|
+
// network drop, so it is rejected under its own name.
|
|
817
|
+
if (conn.sock.destroyed) return onClose();
|
|
818
|
+
try { conn.send(frame); } catch (err) { give(reject, err); }
|
|
720
819
|
});
|
|
721
820
|
|
|
722
821
|
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
@@ -758,6 +857,9 @@ export function createEngine({
|
|
|
758
857
|
// A deck we DO have a pin for was checked before this line: connectToPeer
|
|
759
858
|
// was given expectPub and refuses a different key at that address.
|
|
760
859
|
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
860
|
+
// Said as this deck's own setting, not as a fault: the row the panel
|
|
861
|
+
// draws for it is a state the owner chose (see WIRE_ANSWERS).
|
|
862
|
+
if (cfg.pairingMode === "invite") throw new Error("this deck pairs only by invite");
|
|
761
863
|
if (!peer.typed || wasUnpaired(conn.peerFp)) {
|
|
762
864
|
// The same row the listener's own `onPending` draws, from the other
|
|
763
865
|
// direction: this deck dialled rather than being dialled, and the
|
|
@@ -782,12 +884,21 @@ export function createEngine({
|
|
|
782
884
|
// on — so the deck being asked knows both without dialling back, which a
|
|
783
885
|
// deck with no address for this one never could. An older deck reads the
|
|
784
886
|
// question's `t` and card and nothing else, so it answers as it always did.
|
|
887
|
+
const stillPaired = () => session === startedIn && cfg.enabled
|
|
888
|
+
&& trustedPeer(cfg.trusted, conn.peerFp)?.pub === conn.peerPub;
|
|
889
|
+
if (!stillPaired()) throw new Error("peer no longer paired");
|
|
785
890
|
const mine = await localAccounts();
|
|
891
|
+
// The owner can revoke trust or disable sync while the store is read.
|
|
892
|
+
// Never send this deck's account identities on that old connection.
|
|
893
|
+
if (!stillPaired()) throw new Error("peer no longer paired");
|
|
786
894
|
const theirs = await ask({
|
|
787
895
|
t: "manifest", accounts: manifestFor(mine, cfg.shared),
|
|
788
896
|
...currentFor(mine, cfg.shared, cfg.shareActive),
|
|
789
897
|
...cardFor(conn.key, conn.peerFp),
|
|
790
898
|
});
|
|
899
|
+
// A response from a round that was stopped or unpaired is stale even
|
|
900
|
+
// when the peer had already sent it before the setting changed.
|
|
901
|
+
if (!stillPaired()) throw new Error("peer no longer paired");
|
|
791
902
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
792
903
|
const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
|
|
793
904
|
if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
|
|
@@ -813,8 +924,15 @@ export function createEngine({
|
|
|
813
924
|
// importAccount untyped, which `offered`'s filter would have caught.
|
|
814
925
|
const wanted = plan(mine, list)
|
|
815
926
|
.filter(step => step.action === "add" || cfg.shared.includes(step.key));
|
|
816
|
-
|
|
927
|
+
// TWO CHECKS, BECAUSE THEY END DIFFERENT THINGS. Losing the session —
|
|
928
|
+
// LAN switched off, the peer unpaired (`stillPaired`, above) — ends the
|
|
929
|
+
// round. A heal unticked mid-round ends only that heal: the adds behind
|
|
930
|
+
// it need no tick, and the skipped row says why rather than vanishing.
|
|
931
|
+
const stillWanted = step => step.action !== "heal" || cfg.shared.includes(step.key);
|
|
932
|
+
let cut = false;
|
|
817
933
|
for (const step of wanted) {
|
|
934
|
+
if (!stillPaired()) { cut = true; break; }
|
|
935
|
+
if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
|
|
818
936
|
const nonce = randomBytes(12).toString("hex");
|
|
819
937
|
const reply = await ask({
|
|
820
938
|
t: "want", key: step.key, nonce,
|
|
@@ -822,17 +940,31 @@ export function createEngine({
|
|
|
822
940
|
nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
|
|
823
941
|
}),
|
|
824
942
|
});
|
|
825
|
-
if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why
|
|
943
|
+
if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: peerWhy(reply?.why) }); continue; }
|
|
826
944
|
const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
|
|
827
945
|
if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
|
|
946
|
+
// Unpairing, disabling LAN, or unticking a heal while export was in
|
|
947
|
+
// progress takes effect before the received credential touches disk.
|
|
948
|
+
if (!stillPaired()) { cut = true; break; }
|
|
949
|
+
if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
|
|
828
950
|
// A verdict rather than a boolean, because "refused" and "kept the
|
|
829
951
|
// slot it already has" are different things to tell somebody and the
|
|
830
952
|
// second one used to be reported as success. A bare `true` is still
|
|
831
|
-
// accepted: the suite drives this with one.
|
|
953
|
+
// accepted: the suite drives this with one. `ok` here means the login
|
|
954
|
+
// LANDED; whether this deck can then use it is checked after the loop
|
|
955
|
+
// and rides on the same row as a warning.
|
|
832
956
|
// The step goes down with the blob: the wiring has to know WHICH account
|
|
833
957
|
// it is placing before it may treat a decline as an empty slot rather
|
|
834
958
|
// than as a healthy one.
|
|
835
|
-
|
|
959
|
+
let got;
|
|
960
|
+
try { got = await importAccount(blob, step); }
|
|
961
|
+
catch {
|
|
962
|
+
// One local store failure must not erase earlier arrivals or stop
|
|
963
|
+
// independent logins from being received. Store diagnostics can
|
|
964
|
+
// contain credential material, so only a fixed verdict leaves here.
|
|
965
|
+
done.push({ ...step, ok: false, why: "import failed" });
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
836
968
|
const ok = got === true || got?.ok === true;
|
|
837
969
|
// AN ACCOUNT THAT ARRIVED HERE IS SHARED ONWARD (#1188). People forget
|
|
838
970
|
// to tick it, and a group where one machine can heal the others and the
|
|
@@ -846,17 +978,44 @@ export function createEngine({
|
|
|
846
978
|
// add for one; and a tailnet reaches further than the person's own
|
|
847
979
|
// machines, which is a decision they make for themselves rather than
|
|
848
980
|
// one an arrival makes for them.
|
|
849
|
-
|
|
981
|
+
// The store can finish an import after the owner disabled LAN or
|
|
982
|
+
// revoked this peer. Keep the imported slot, but do not turn it into
|
|
983
|
+
// a newly shared credential on behalf of an obsolete transfer.
|
|
984
|
+
if (ok && stillPaired() && ticksOnArrival(step, viaOf(peer))) {
|
|
850
985
|
try { await onShared?.(step.key); }
|
|
851
986
|
catch { /* the account is here; the tick is retried the next time one arrives */ }
|
|
852
987
|
}
|
|
853
988
|
done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
|
|
854
989
|
}
|
|
855
|
-
|
|
990
|
+
// A ROUND FROM A SESSION THAT ENDED SAYS NOTHING. LAN was switched off
|
|
991
|
+
// under it, and possibly on again: a cut-short list is not "all logins
|
|
992
|
+
// fine", and it is not this session's to report.
|
|
993
|
+
if (session !== startedIn) return done;
|
|
994
|
+
// Unpaired mid-round: said as such, not as the short list that reads
|
|
995
|
+
// "all logins fine".
|
|
996
|
+
if (cut) throw new Error("peer no longer paired");
|
|
997
|
+
// WHAT ARRIVED, CHECKED ONCE, AFTER THE LAST QUESTION. An import that
|
|
998
|
+
// exited cleanly can still have left a login this process cannot read (a
|
|
999
|
+
// Mac's Keychain, from SSH or a LaunchAgent). Such a row stays `ok` — it
|
|
1000
|
+
// DID arrive, and was ticked onward above — and carries the reason as a
|
|
1001
|
+
// warning. After the loop because the check is a usage collection that
|
|
1002
|
+
// can outlast the peer's thirty-second idle timer, and one ask covers
|
|
1003
|
+
// every login the round brought.
|
|
1004
|
+
await markArrivals(done);
|
|
1005
|
+
lastRound.set(peer.fp, { at: now(), name: peer.name, offered: list.length, done });
|
|
856
1006
|
if (done.length) onChange?.();
|
|
857
1007
|
return done;
|
|
858
1008
|
} catch (err) {
|
|
859
|
-
|
|
1009
|
+
// Nor does its failure: "peer no longer paired" from a round that LAN
|
|
1010
|
+
// being switched off ended is about the old session, and would stand on
|
|
1011
|
+
// a row that is paired and fine until the next round replaced it.
|
|
1012
|
+
if (session !== startedIn) return [];
|
|
1013
|
+
// A round cut short still reports what arrived, so the logins it did
|
|
1014
|
+
// bring are checked the same way a finished round's are.
|
|
1015
|
+
await markArrivals(done);
|
|
1016
|
+
if (session !== startedIn) return [];
|
|
1017
|
+
lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message, done });
|
|
1018
|
+
if (done.length) onChange?.();
|
|
860
1019
|
// A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
|
|
861
1020
|
// from a paired deck's inbound call, and this round was the test of
|
|
862
1021
|
// whether the call can be returned. It could not — a strict NAT, a
|
|
@@ -871,7 +1030,7 @@ export function createEngine({
|
|
|
871
1030
|
lastRound.delete(peer.fp);
|
|
872
1031
|
onChange?.();
|
|
873
1032
|
}
|
|
874
|
-
return
|
|
1033
|
+
return done;
|
|
875
1034
|
} finally {
|
|
876
1035
|
conn?.sock?.destroy();
|
|
877
1036
|
}
|
|
@@ -904,6 +1063,7 @@ export function createEngine({
|
|
|
904
1063
|
|
|
905
1064
|
const oneRound = async () => {
|
|
906
1065
|
if (!beacon) return [];
|
|
1066
|
+
const startedIn = session;
|
|
907
1067
|
const all = [];
|
|
908
1068
|
// Heard first, typed second, and a typed one is skipped when the beacon
|
|
909
1069
|
// already found that address: otherwise a deck that is both would be dialled
|
|
@@ -934,8 +1094,13 @@ export function createEngine({
|
|
|
934
1094
|
// time anyway (the mutex in store-lock.mjs), and two peers healing the
|
|
935
1095
|
// same account at once would race for a slot number claude-swap assigns
|
|
936
1096
|
// as max+1 without a lock of its own.
|
|
1097
|
+
//
|
|
1098
|
+
// And given up when LAN is switched off under it: the rest of the list
|
|
1099
|
+
// belongs to no session, and dialling it only delays the next round.
|
|
1100
|
+
if (session !== startedIn) return all;
|
|
937
1101
|
all.push(...await roundWith(peer));
|
|
938
1102
|
}
|
|
1103
|
+
if (session !== startedIn) return all;
|
|
939
1104
|
roundAt = now();
|
|
940
1105
|
return all;
|
|
941
1106
|
};
|
|
@@ -965,6 +1130,8 @@ export function createEngine({
|
|
|
965
1130
|
|
|
966
1131
|
/** The whole round in flight or waiting its turn, or null. See `round` below. */
|
|
967
1132
|
let _round = null;
|
|
1133
|
+
/** The session `_round` was asked for in. */
|
|
1134
|
+
let _roundIn = -1;
|
|
968
1135
|
|
|
969
1136
|
/**
|
|
970
1137
|
* One round at a time, and the one already running is the answer (#1040).
|
|
@@ -992,7 +1159,18 @@ export function createEngine({
|
|
|
992
1159
|
* never a check: joining a check would answer "ask every deck" with one
|
|
993
1160
|
* deck's work and leave the rest waiting another minute.
|
|
994
1161
|
*/
|
|
995
|
-
|
|
1162
|
+
//
|
|
1163
|
+
// NEVER A ROUND FROM A SESSION THAT ENDED. LAN switched off and on again
|
|
1164
|
+
// while one ran: that round stops at its next peer and reports nothing, so
|
|
1165
|
+
// joining it would answer the new session's first tick with nothing and
|
|
1166
|
+
// leave every deck unasked for a whole SYNC_MS. The new one queues behind it.
|
|
1167
|
+
const round = () => {
|
|
1168
|
+
if (_round && _roundIn === session) return _round;
|
|
1169
|
+
_roundIn = session;
|
|
1170
|
+
const mine = inTurn(oneRound).finally(() => { if (_round === mine) _round = null; });
|
|
1171
|
+
_round = mine;
|
|
1172
|
+
return mine;
|
|
1173
|
+
};
|
|
996
1174
|
|
|
997
1175
|
return {
|
|
998
1176
|
async apply(next) {
|
|
@@ -1002,6 +1180,13 @@ export function createEngine({
|
|
|
1002
1180
|
engine = this;
|
|
1003
1181
|
const was = cfg;
|
|
1004
1182
|
cfg = { ...cfg, ...next };
|
|
1183
|
+
// A request made before invite-only was enabled must not survive the
|
|
1184
|
+
// switch and become an automatic approval when automatic mode returns.
|
|
1185
|
+
// A fresh handshake after that switch may request pairing again.
|
|
1186
|
+
if (was.pairingMode !== "invite" && cfg.pairingMode === "invite" && pending.size) {
|
|
1187
|
+
pending.clear();
|
|
1188
|
+
onChange?.();
|
|
1189
|
+
}
|
|
1005
1190
|
// TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
|
|
1006
1191
|
// this on with two rows sitting in the panel means those two as much as
|
|
1007
1192
|
// the next one, and leaving them queued behind a setting called
|
|
@@ -1010,8 +1195,8 @@ export function createEngine({
|
|
|
1010
1195
|
// PER ROUTE, because each pair of switches answers for its own: turning
|
|
1011
1196
|
// the local one on does not answer a tailnet request, and turning the
|
|
1012
1197
|
// Tailscale one on answers only the owner's own machines.
|
|
1013
|
-
const yes = c => ({ lan: !!c.autoAccept, tailscale: !!c.tailscale && c.tailscaleAccept !== false });
|
|
1014
|
-
const ask = c => ({ lan: !!c.autoAsk, tailscale: !!c.tailscale && c.tailscaleAsk !== false });
|
|
1198
|
+
const yes = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAccept, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAccept !== false });
|
|
1199
|
+
const ask = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAsk, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAsk !== false });
|
|
1015
1200
|
const turnedOn = (f, via) => !f(was)[via] && f(cfg)[via];
|
|
1016
1201
|
const mayAnswer = p => (p.via === "tailscale" ? turnedOn(yes, "tailscale") && p.own : turnedOn(yes, "lan"));
|
|
1017
1202
|
for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) this.accept(fp, { byHand: false });
|
|
@@ -1028,8 +1213,9 @@ export function createEngine({
|
|
|
1028
1213
|
|| was.secret !== cfg.secret
|
|
1029
1214
|
|| was.name !== cfg.name;
|
|
1030
1215
|
if (!restart) { syncTailnet(); return; }
|
|
1031
|
-
this.stop();
|
|
1216
|
+
this.stop(cfg.enabled);
|
|
1032
1217
|
if (!cfg.enabled) return;
|
|
1218
|
+
const startedIn = generation;
|
|
1033
1219
|
identity = identityFrom(cfg.secret);
|
|
1034
1220
|
// Hand the caller a key to keep when there was none, so the next start is
|
|
1035
1221
|
// the same deck rather than a stranger to everybody who paired with it.
|
|
@@ -1041,7 +1227,7 @@ export function createEngine({
|
|
|
1041
1227
|
// still works after this deck restarts. createSyncServer falls through to
|
|
1042
1228
|
// an OS-chosen one when it is taken, and the caller stores whatever came
|
|
1043
1229
|
// back — so the pin drifts to a free port rather than failing.
|
|
1044
|
-
|
|
1230
|
+
const startingServer = createSyncServer({
|
|
1045
1231
|
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
1046
1232
|
name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
|
|
1047
1233
|
// See inboundAt. Every connection passes here, including one that goes
|
|
@@ -1079,26 +1265,34 @@ export function createEngine({
|
|
|
1079
1265
|
// told no again rather than becoming a row somebody has to answer
|
|
1080
1266
|
// twice. The socket sends the reason; this only knows the name.
|
|
1081
1267
|
declined: fp => declined.has(fp),
|
|
1268
|
+
// Read on every handshake rather than captured, so switching the mode
|
|
1269
|
+
// takes effect on the next caller without restarting the listener.
|
|
1270
|
+
inviteOnly: () => cfg.pairingMode === "invite",
|
|
1082
1271
|
// The same helper the outbound round uses, because a deck that called
|
|
1083
1272
|
// in and a deck this one called have proved exactly the same thing —
|
|
1084
1273
|
// see askToAccept.
|
|
1085
1274
|
onPending: askToAccept,
|
|
1086
1275
|
});
|
|
1276
|
+
server = startingServer;
|
|
1087
1277
|
let port;
|
|
1088
1278
|
try {
|
|
1089
|
-
port = await
|
|
1279
|
+
port = await startingServer.start();
|
|
1090
1280
|
} catch (err) {
|
|
1281
|
+
if (startedIn !== generation || server !== startingServer || !cfg.enabled) return;
|
|
1091
1282
|
// Kept, so the panel can say it. Rethrown, because the caller's own
|
|
1092
1283
|
// catch is what leaves the engine stopped rather than half-started.
|
|
1093
1284
|
stalled = err?.message ?? String(err);
|
|
1094
1285
|
throw err;
|
|
1095
1286
|
}
|
|
1287
|
+
// An immediately cancelled start must not resurrect its listener or
|
|
1288
|
+
// beacon after stop(), or overwrite a newer start's server.
|
|
1289
|
+
if (startedIn !== generation || server !== startingServer || !cfg.enabled || port == null) return;
|
|
1096
1290
|
stalled = null;
|
|
1097
1291
|
// From here the socket is accepting, so this is the moment the silence
|
|
1098
1292
|
// starts being about the network rather than about a deck still starting.
|
|
1099
1293
|
listeningSince = now();
|
|
1100
1294
|
if (port !== cfg.port) onPort?.(port);
|
|
1101
|
-
|
|
1295
|
+
const startingBeacon = createBeacon({
|
|
1102
1296
|
port, name: cfg.name, fp: identity.fp,
|
|
1103
1297
|
trusted: () => cfg.trusted,
|
|
1104
1298
|
// The owner's own machines on the tailnet, while the switch is on.
|
|
@@ -1158,7 +1352,9 @@ export function createEngine({
|
|
|
1158
1352
|
onError, now,
|
|
1159
1353
|
...(createSocket ? { createSocket } : {}),
|
|
1160
1354
|
});
|
|
1161
|
-
|
|
1355
|
+
beacon = startingBeacon;
|
|
1356
|
+
await startingBeacon.start();
|
|
1357
|
+
if (startedIn !== generation || beacon !== startingBeacon || !cfg.enabled) return;
|
|
1162
1358
|
// One read of the tailnet whatever the switch says, so a packet from a
|
|
1163
1359
|
// tailnet address is told apart from a local one from the first minute.
|
|
1164
1360
|
void tailnet?.freshen?.(TAILNET_IDLE_MS);
|
|
@@ -1167,7 +1363,7 @@ export function createEngine({
|
|
|
1167
1363
|
// between rounds is not one number: see ASKING_MS.
|
|
1168
1364
|
const tick = async () => {
|
|
1169
1365
|
try { await round(); } catch { /* a round reports itself, per peer */ }
|
|
1170
|
-
if (!
|
|
1366
|
+
if (startedIn !== generation || beacon !== startingBeacon || !cfg.enabled) return;
|
|
1171
1367
|
timer = setTimeout(() => { void tick(); }, waitingOnSomebody() ? ASKING_MS : SYNC_MS);
|
|
1172
1368
|
timer.unref?.();
|
|
1173
1369
|
};
|
|
@@ -1226,8 +1422,11 @@ export function createEngine({
|
|
|
1226
1422
|
if (!inv) return { ok: false, reason: "not_an_invite" };
|
|
1227
1423
|
if (inv.expired) return { ok: false, reason: "expired" };
|
|
1228
1424
|
if (!identity || !server) return { ok: false, reason: "not_running" };
|
|
1425
|
+
const startedIn = generation;
|
|
1426
|
+
const stillJoining = () => generation === startedIn && cfg.enabled && !!server;
|
|
1229
1427
|
const tried = [];
|
|
1230
1428
|
for (const at of inv.addrs) {
|
|
1429
|
+
if (!stillJoining()) return { ok: false, reason: "not_running", tried };
|
|
1231
1430
|
let conn = null;
|
|
1232
1431
|
try {
|
|
1233
1432
|
conn = await connectToPeer({
|
|
@@ -1244,6 +1443,9 @@ export function createEngine({
|
|
|
1244
1443
|
inviteProvesBack: inv.provesBack,
|
|
1245
1444
|
sealFrames, ephemeral,
|
|
1246
1445
|
});
|
|
1446
|
+
// The handshake can complete after LAN was switched off (or the
|
|
1447
|
+
// identity was restarted). Never persist a pin from that old join.
|
|
1448
|
+
if (!stillJoining()) return { ok: false, reason: "not_running", tried };
|
|
1247
1449
|
const { list } = addTrusted(cfg.trusted, {
|
|
1248
1450
|
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
1249
1451
|
});
|
|
@@ -1281,7 +1483,7 @@ export function createEngine({
|
|
|
1281
1483
|
const asked = pending.get(fp) ?? null;
|
|
1282
1484
|
const heard = strangers.get(fp) ?? null;
|
|
1283
1485
|
const seen = asked ?? heard;
|
|
1284
|
-
if (!seen) return null;
|
|
1486
|
+
if (!seen || cfg.pairingMode === "invite") return null;
|
|
1285
1487
|
if (wasUnpaired(fp) && !byHand) return null;
|
|
1286
1488
|
// TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
|
|
1287
1489
|
//
|
|
@@ -1494,6 +1696,7 @@ export function createEngine({
|
|
|
1494
1696
|
// arrives is answered here or answered for you.
|
|
1495
1697
|
autoAsk: !!cfg.autoAsk,
|
|
1496
1698
|
autoAccept: !!cfg.autoAccept,
|
|
1699
|
+
pairingMode: cfg.pairingMode === "invite" ? "invite" : "automatic",
|
|
1497
1700
|
// Whether paired decks are told which shared account this one is on.
|
|
1498
1701
|
shareActive: cfg.shareActive !== false,
|
|
1499
1702
|
// Discovery over Tailscale: whether this machine has it at all, which
|
|
@@ -1636,7 +1839,10 @@ export function createEngine({
|
|
|
1636
1839
|
})() : [],
|
|
1637
1840
|
};
|
|
1638
1841
|
},
|
|
1639
|
-
stop() {
|
|
1842
|
+
stop(restarting = false) {
|
|
1843
|
+
generation++;
|
|
1844
|
+
if (!restarting) session++;
|
|
1845
|
+
tailRefreshGeneration++;
|
|
1640
1846
|
if (timer) clearTimeout(timer);
|
|
1641
1847
|
if (tailTimer) clearInterval(tailTimer);
|
|
1642
1848
|
tailTimer = null;
|