ccdeck 3.26.1 → 3.26.4
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 +20 -1
- package/dist/web/assets/{BrowserWatchModal-CnAbmyRH.js → BrowserWatchModal-BUWNIaK8.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BCpieMjP.js → UsageHistoryModal-DlRe0P3F.js} +1 -1
- package/dist/web/assets/{index-CO8B_qBe.js → index-CPHonXg4.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/deck-prefs.mjs +42 -0
- package/src/server/index.mjs +40 -22
- package/src/server/lan-engine.mjs +110 -2
- package/src/server/lan-reach.mjs +32 -9
- package/src/server/lan-socket.mjs +9 -0
package/dist/web/index.html
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
45
45
|
})();
|
|
46
46
|
</script>
|
|
47
|
-
<script type="module" crossorigin src="/assets/index-
|
|
47
|
+
<script type="module" crossorigin src="/assets/index-CPHonXg4.js"></script>
|
|
48
48
|
<link rel="stylesheet" crossorigin href="/assets/index-DlaHgzye.css">
|
|
49
49
|
</head>
|
|
50
50
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.26.
|
|
3
|
+
"version": "3.26.4",
|
|
4
4
|
"description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -415,6 +415,48 @@ export async function updatePrefs(mutate, home = deckDataDir(), deps = {}) {
|
|
|
415
415
|
return queued(() => save(mutate, home, deps));
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
/**
|
|
419
|
+
* The two `mutate`s the three callers above hand updatePrefs, named so that the
|
|
420
|
+
* callers and prefs-update-1041.test.ts run the same function. The suite used to
|
|
421
|
+
* carry its own copy of each closure and test the copy (#1168), which stays
|
|
422
|
+
* green however the one in index.mjs is edited.
|
|
423
|
+
*/
|
|
424
|
+
|
|
425
|
+
/** `lan.manual` with `entry` on the end, or no change when it is there already.
|
|
426
|
+
* What `onDial` and the accept route both write. */
|
|
427
|
+
export function withManualEntry(entry) {
|
|
428
|
+
return prev => {
|
|
429
|
+
const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
|
|
430
|
+
return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* `lan.shared` with `key` on the end, or no change when it is already there.
|
|
436
|
+
*
|
|
437
|
+
* The tick an account gets when it ARRIVES over the local network (#1188): the
|
|
438
|
+
* group already has that login, so a deck that was just given one is as useful
|
|
439
|
+
* to the next machine as the deck that gave it. Only on arrival — an untick
|
|
440
|
+
* afterwards is the person's answer and nothing re-ticks it, because an account
|
|
441
|
+
* this deck already holds is never added again.
|
|
442
|
+
*/
|
|
443
|
+
export function withShared(key) {
|
|
444
|
+
return prev => {
|
|
445
|
+
const shared = Array.isArray(prev?.lan?.shared) ? prev.lan.shared : [];
|
|
446
|
+
return !key || shared.includes(key) ? null : { lan: { shared: [...shared, key] } };
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** `lan.aliases` with `fp` called `name`, or without `fp` when the name is
|
|
451
|
+
* empty. The alias route's write; the whole map, rebuilt from the one read. */
|
|
452
|
+
export function withAlias(fp, name) {
|
|
453
|
+
return prev => {
|
|
454
|
+
const next = { ...(prev?.lan?.aliases ?? {}) };
|
|
455
|
+
if (name) next[fp] = name; else delete next[fp];
|
|
456
|
+
return { lan: { aliases: next } };
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
418
460
|
/** One read-modify-write, behind every other one. Both entry points go through
|
|
419
461
|
* here so there is a single queue and a single merge. */
|
|
420
462
|
function queued(job) {
|
package/src/server/index.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { createOutputWatch } from "./output-watch.mjs";
|
|
|
34
34
|
import { RECAP_MARK, foldRecapLine } from "./session-recap.mjs";
|
|
35
35
|
import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
|
|
36
36
|
import { createPresence } from "./presence.mjs";
|
|
37
|
-
import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
|
|
37
|
+
import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
|
|
38
38
|
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
39
39
|
import { createTailnet, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
|
|
40
40
|
import { portHolder } from "./port-holder.mjs";
|
|
@@ -3962,6 +3962,19 @@ const lanEngine = createEngine({
|
|
|
3962
3962
|
// listener had one is missing half of them — see forgetReach.
|
|
3963
3963
|
forgetReach();
|
|
3964
3964
|
},
|
|
3965
|
+
// An account arrived from a paired deck, and this deck now offers it too
|
|
3966
|
+
// (#1188). Written before the engine hears it, so a restart between the two
|
|
3967
|
+
// leaves the tick on disk rather than only in the running engine's copy.
|
|
3968
|
+
onShared: async key => {
|
|
3969
|
+
try {
|
|
3970
|
+
const before = _prefs;
|
|
3971
|
+
_prefs = await updatePrefs(withShared(key));
|
|
3972
|
+
if (_prefs?.lan?.shared === before?.lan?.shared) return; // it was already ticked
|
|
3973
|
+
await lanEngine.apply({ shared: _prefs.lan.shared });
|
|
3974
|
+
} catch (err) {
|
|
3975
|
+
console.error(`${PRODUCT}: lan sync could not share the account it just received:`, err?.message ?? err);
|
|
3976
|
+
}
|
|
3977
|
+
},
|
|
3965
3978
|
// A deck was accepted or unpaired. Written straight through, because the
|
|
3966
3979
|
// trusted list is the whole of who this deck will talk to and a list that
|
|
3967
3980
|
// only existed in memory would drop every pairing on restart.
|
|
@@ -3982,10 +3995,7 @@ const lanEngine = createEngine({
|
|
|
3982
3995
|
// is the WHOLE array, so a stale one wins. Pressing accept on a heard deck
|
|
3983
3996
|
// at the moment an invite round fires this dropped the dialled address out
|
|
3984
3997
|
// of `lan.manual`, which is the failure the comment above describes.
|
|
3985
|
-
_prefs = await updatePrefs(
|
|
3986
|
-
const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
|
|
3987
|
-
return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
|
|
3988
|
-
});
|
|
3998
|
+
_prefs = await updatePrefs(withManualEntry(entry));
|
|
3989
3999
|
// AND RECONCILE THE ENGINE'S DIAL LIST with what was just written. The
|
|
3990
4000
|
// disk is now right, but `setPeers` replaces the list wholesale from
|
|
3991
4001
|
// `_prefs` on every settings write — so a write that raced this one, and
|
|
@@ -4360,12 +4370,7 @@ async function handleLanPeer(req, res) {
|
|
|
4360
4370
|
// Inside the job, like `onDial` — the same whole-array patch computed
|
|
4361
4371
|
// from the same stale copy, and the same address lost when two of them
|
|
4362
4372
|
// land in one turn.
|
|
4363
|
-
try {
|
|
4364
|
-
_prefs = await updatePrefs(prev => {
|
|
4365
|
-
const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
|
|
4366
|
-
return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
|
|
4367
|
-
});
|
|
4368
|
-
}
|
|
4373
|
+
try { _prefs = await updatePrefs(withManualEntry(entry)); }
|
|
4369
4374
|
catch { /* it is dialled this session; the next accept re-adds it */ }
|
|
4370
4375
|
}
|
|
4371
4376
|
return send(res, 200, { ok: true, added, ...lanEngine.status() });
|
|
@@ -4391,11 +4396,7 @@ async function handleLanPeer(req, res) {
|
|
|
4391
4396
|
case "alias": {
|
|
4392
4397
|
if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
4393
4398
|
const name = cleanAlias(body.name);
|
|
4394
|
-
_prefs = await updatePrefs(
|
|
4395
|
-
const next = { ...(prev?.lan?.aliases ?? {}) };
|
|
4396
|
-
if (name) next[fp] = name; else delete next[fp];
|
|
4397
|
-
return { lan: { aliases: next } };
|
|
4398
|
-
});
|
|
4399
|
+
_prefs = await updatePrefs(withAlias(fp, name));
|
|
4399
4400
|
await lanEngine.apply({ aliases: _prefs.lan.aliases });
|
|
4400
4401
|
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
4401
4402
|
}
|
|
@@ -6756,10 +6757,25 @@ export function requestUrl(rawUrl) {
|
|
|
6756
6757
|
// So the Host check runs for every method now. What it asks is only the
|
|
6757
6758
|
// rebinding question — did this request arrive addressed to a name that can
|
|
6758
6759
|
// only ever be this machine — and it asks it of browser-shaped requests alone,
|
|
6759
|
-
// meaning anything carrying an Origin or
|
|
6760
|
-
//
|
|
6761
|
-
// hook/hook.js, a plain Node http.request from the user's own machine,
|
|
6762
|
-
// keeps reaching the deck under whatever name it used before.
|
|
6760
|
+
// meaning anything carrying an Origin, fetch metadata or a Referer. A client
|
|
6761
|
+
// sending none of them is not a page and has no ambient authority to borrow:
|
|
6762
|
+
// that is hook/hook.js, a plain Node http.request from the user's own machine,
|
|
6763
|
+
// and it keeps reaching the deck under whatever name it used before.
|
|
6764
|
+
//
|
|
6765
|
+
// THE REFERER IS ON THAT LIST because on one browser it is the only mark a page
|
|
6766
|
+
// leaves (#1168). A same-origin GET carries no Origin, and Safari 16.0-16.3
|
|
6767
|
+
// sends no Sec-Fetch-Site — the premise isAuthorizedDataRead's fallback is
|
|
6768
|
+
// built on — so a rebound page on that browser sent `Host: attacker.example:
|
|
6769
|
+
// 4317` and `Referer: http://attacker.example:4317/` and nothing else, was not
|
|
6770
|
+
// browser-shaped by the test as it stood, and was answered. Measured against a
|
|
6771
|
+
// running deck before this line changed: /api/health (the absolute workspace
|
|
6772
|
+
// path), /api/hook-challenge (the proof oracle, which handleHookChallenge says
|
|
6773
|
+
// a rebound page cannot see), /api/system/processes and the page itself all
|
|
6774
|
+
// answered 200. The guarded reads held only because isAuthorizedDataRead
|
|
6775
|
+
// happens to test the Host before it reads the Referer. No client of this
|
|
6776
|
+
// server that is not a browser sends a Referer — hook.js, the desktop app and
|
|
6777
|
+
// bin/ send none — so nothing that reached the deck before is turned away by
|
|
6778
|
+
// this, and the deck's own page names a loopback Host whatever it sends.
|
|
6763
6779
|
//
|
|
6764
6780
|
// Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
|
|
6765
6781
|
// applies. `cross-site` on a read is an ordinary top-level navigation — a link
|
|
@@ -6767,9 +6783,10 @@ export function requestUrl(rawUrl) {
|
|
|
6767
6783
|
// the deck's own UI on the deck's own origin, which is not an attack and used
|
|
6768
6784
|
// to work. Rebinding does not need that test either: a rebound page's requests
|
|
6769
6785
|
// report `same-origin`, and it is the Host that gives it away.
|
|
6770
|
-
export function isTrustedRead({ origin, host, secFetchSite } = {}) {
|
|
6786
|
+
export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
|
|
6771
6787
|
const browserShaped = (typeof origin === "string" && origin !== "")
|
|
6772
|
-
|| (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
|
|
6788
|
+
|| (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
|
|
6789
|
+
|| (typeof referer === "string" && referer.trim() !== "");
|
|
6773
6790
|
if (!browserShaped) return true;
|
|
6774
6791
|
return isLoopbackHost(host);
|
|
6775
6792
|
}
|
|
@@ -7377,6 +7394,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7377
7394
|
origin: req.headers.origin,
|
|
7378
7395
|
host: req.headers.host,
|
|
7379
7396
|
secFetchSite: req.headers["sec-fetch-site"],
|
|
7397
|
+
referer: req.headers.referer,
|
|
7380
7398
|
})) {
|
|
7381
7399
|
return send(res, 403, { error: "cross-site request blocked" });
|
|
7382
7400
|
}
|
|
@@ -194,9 +194,26 @@ export function heardCurrent(raw, list) {
|
|
|
194
194
|
* Injected rather than imported so a test can run a whole round — two engines,
|
|
195
195
|
* two fake stores, one real socket pair — without claude-swap on the machine.
|
|
196
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* Whether an account this round just placed is ticked for sharing here (#1188).
|
|
199
|
+
*
|
|
200
|
+
* ONLY AN ADD: a heal needed the tick to happen at all — roundWith asks for a
|
|
201
|
+
* heal only for an account this deck already shares — so there is nothing to
|
|
202
|
+
* add for one, and an account healed after somebody unticked it must not be
|
|
203
|
+
* ticked again behind them.
|
|
204
|
+
*
|
|
205
|
+
* ONLY FROM THE LOCAL NETWORK: the reasoning for the default is that the login
|
|
206
|
+
* came from the group and the group therefore has it, and a tailnet can reach
|
|
207
|
+
* further than one person's own machines. Sharing there stays a decision
|
|
208
|
+
* somebody makes rather than one an arrival makes for them.
|
|
209
|
+
*/
|
|
210
|
+
export function ticksOnArrival(step, via) {
|
|
211
|
+
return !!step?.key && step.action === "add" && via !== "tailscale";
|
|
212
|
+
}
|
|
213
|
+
|
|
197
214
|
export function createEngine({
|
|
198
215
|
readAccounts, exportAccount, importAccount,
|
|
199
|
-
onChange, onError, onIdentity, onPort, onTrust, onDial, now = Date.now,
|
|
216
|
+
onChange, onError, onIdentity, onPort, onTrust, onDial, onShared, now = Date.now,
|
|
200
217
|
/**
|
|
201
218
|
* The UDP socket the beacon shouts through, injectable for the same reason
|
|
202
219
|
* lan-socket exposes it — and for one more that only showed up in use.
|
|
@@ -351,6 +368,13 @@ export function createEngine({
|
|
|
351
368
|
* still be said to come over the tailnet or the local network — it has no
|
|
352
369
|
* address of its own here, and without this its row could not say which. */
|
|
353
370
|
const spokeFrom = new Map();
|
|
371
|
+
/** Addresses this deck added because a paired deck called in from them and
|
|
372
|
+
* nothing here dialled it — see learnCaller. Kept until a round proves the
|
|
373
|
+
* address answers: one that does is an ordinary dialled peer from then on
|
|
374
|
+
* and leaves this set; one that does not is a caller this deck cannot reach
|
|
375
|
+
* back (a strict NAT, a one-way path), and its row is taken away again so it
|
|
376
|
+
* reverts to "calls in" rather than failing every round. */
|
|
377
|
+
const calledBack = new Set();
|
|
354
378
|
/**
|
|
355
379
|
* When a connection from ANOTHER MACHINE last arrived on the sync listener.
|
|
356
380
|
*
|
|
@@ -457,13 +481,59 @@ export function createEngine({
|
|
|
457
481
|
* connection's — see sessionKey. `ctx.send` seals whatever it is handed when
|
|
458
482
|
* both ends said they seal, so nothing below has to know which kind of deck
|
|
459
483
|
* asked — see frameChannel. */
|
|
484
|
+
/** Does this deck already hold an address it dials for `fp`? A beacon row it
|
|
485
|
+
* still hears, or a typed/learned row that answered as that deck. When
|
|
486
|
+
* neither is true, the only way it ever reaches that deck is if the deck
|
|
487
|
+
* keeps calling — and a called deck is never pulled from. */
|
|
488
|
+
const dialsAlready = fp => {
|
|
489
|
+
if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && stillListed(p, now()))) return true;
|
|
490
|
+
for (const [at, met] of learned) if (met?.fp === fp && manual.has(at)) return true;
|
|
491
|
+
return false;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* A PAIRED DECK THAT CALLS IN, AND NOTHING HERE DIALS IT.
|
|
496
|
+
*
|
|
497
|
+
* Accounts move only toward the deck that dials — roundWith pulls, serve only
|
|
498
|
+
* answers — so a deck this one holds no address for can offer everything and
|
|
499
|
+
* this one takes nothing. It is the exact state a deck falls into when it
|
|
500
|
+
* cannot hear beacons (a firewall, or Tailscale holding the discovery port):
|
|
501
|
+
* every peer becomes one that only calls, and no account ever arrives.
|
|
502
|
+
*
|
|
503
|
+
* The call itself is the address. The peer connected FROM somewhere and said
|
|
504
|
+
* in its hello which port it LISTENS on, and that pair is dialable. Adding it
|
|
505
|
+
* makes the next round reach the caller and pull — the same dial-back that
|
|
506
|
+
* accepting a deck and joining by invite already do, extended to a peer that
|
|
507
|
+
* simply calls. In memory, like those two: a settings write clears it and the
|
|
508
|
+
* next call re-adds it, and nothing here writes an address to disk.
|
|
509
|
+
*/
|
|
510
|
+
const learnCaller = ctx => {
|
|
511
|
+
const fp = ctx?.peerFp;
|
|
512
|
+
const at = ctx?.peerAddr;
|
|
513
|
+
const port = ctx?.peerPort;
|
|
514
|
+
if (!fp || !at || !port || !engine) return;
|
|
515
|
+
if (!trustedPeer(cfg.trusted, fp)) return;
|
|
516
|
+
if (dialsAlready(fp)) return;
|
|
517
|
+
// As a row the deck ADDED ITSELF, not one a person typed: capped like every
|
|
518
|
+
// other automatic row, and — through calledBack — taken away again if the
|
|
519
|
+
// address turns out not to answer. The caller is already trusted, so the
|
|
520
|
+
// round dials and pulls without a press; `typed` decides only the cap and
|
|
521
|
+
// the undo, never the trust. See roundWith.
|
|
522
|
+
if (engine.addPeer(at, port, { typed: false })) {
|
|
523
|
+
learned.set(`${at}:${port}`, { fp, name: trustedPeer(cfg.trusted, fp)?.name || "" });
|
|
524
|
+
calledBack.add(`${at}:${port}`);
|
|
525
|
+
onChange?.();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
460
529
|
const serve = async (msg, ctx) => {
|
|
461
530
|
// Before the verbs, and for every one of them: something that proved it
|
|
462
531
|
// holds a key this deck accepted is talking, now.
|
|
463
532
|
if (ctx?.peerFp) {
|
|
464
533
|
spokeAt.set(ctx.peerFp, now());
|
|
465
|
-
const from = String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
534
|
+
const from = ctx.peerAddr || String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
466
535
|
if (from) spokeFrom.set(ctx.peerFp, from);
|
|
536
|
+
learnCaller(ctx);
|
|
467
537
|
}
|
|
468
538
|
try {
|
|
469
539
|
if (msg.t === "manifest") {
|
|
@@ -562,6 +632,10 @@ export function createEngine({
|
|
|
562
632
|
const waitingOnSomebody = () =>
|
|
563
633
|
[...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
|
|
564
634
|
|
|
635
|
+
/** How this deck reached that peer. A beacon row says so; a typed address is
|
|
636
|
+
* read from the routing table, the way the peer list reads it. */
|
|
637
|
+
const viaOf = peer => peer.via ?? (routeTo(peer.addr) ? "tailscale" : "lan");
|
|
638
|
+
|
|
565
639
|
/** Ask one peer what it has, and heal whatever it can heal. */
|
|
566
640
|
const roundWith = async peer => {
|
|
567
641
|
let conn = null;
|
|
@@ -633,6 +707,10 @@ export function createEngine({
|
|
|
633
707
|
// from then on, because "Constantin-PC" is what the person who typed the
|
|
634
708
|
// address was trying to reach.
|
|
635
709
|
learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
|
|
710
|
+
// A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
|
|
711
|
+
// until it proved the deck can reach it; from here it is dialled like any
|
|
712
|
+
// other and is no longer a candidate for the undo below. See learnCaller.
|
|
713
|
+
calledBack.delete(`${peer.addr}:${peer.port}`);
|
|
636
714
|
|
|
637
715
|
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
|
|
638
716
|
// deck we have no pin for used to mean the person at this keyboard put
|
|
@@ -738,6 +816,22 @@ export function createEngine({
|
|
|
738
816
|
// than as a healthy one.
|
|
739
817
|
const got = await importAccount(blob, step);
|
|
740
818
|
const ok = got === true || got?.ok === true;
|
|
819
|
+
// AN ACCOUNT THAT ARRIVED HERE IS SHARED ONWARD (#1188). People forget
|
|
820
|
+
// to tick it, and a group where one machine can heal the others and the
|
|
821
|
+
// others can heal nobody is the shape that costs them: the second
|
|
822
|
+
// machine to lose the same login has to go back to the first, which may
|
|
823
|
+
// be asleep or on another network. Nothing new is exposed — the login
|
|
824
|
+
// came FROM the group, so the group has it.
|
|
825
|
+
//
|
|
826
|
+
// ONLY AN ADD, and only from the local network. A heal already needed
|
|
827
|
+
// the tick to happen at all (the filter above), so there is nothing to
|
|
828
|
+
// add for one; and a tailnet reaches further than the person's own
|
|
829
|
+
// machines, which is a decision they make for themselves rather than
|
|
830
|
+
// one an arrival makes for them.
|
|
831
|
+
if (ok && ticksOnArrival(step, viaOf(peer))) {
|
|
832
|
+
try { await onShared?.(step.key); }
|
|
833
|
+
catch { /* the account is here; the tick is retried the next time one arrives */ }
|
|
834
|
+
}
|
|
741
835
|
done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
|
|
742
836
|
}
|
|
743
837
|
lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
|
|
@@ -745,6 +839,20 @@ export function createEngine({
|
|
|
745
839
|
return done;
|
|
746
840
|
} catch (err) {
|
|
747
841
|
lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
|
|
842
|
+
// A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
|
|
843
|
+
// from a paired deck's inbound call, and this round was the test of
|
|
844
|
+
// whether the call can be returned. It could not — a strict NAT, a
|
|
845
|
+
// one-way path — so the row is removed rather than left to fail every
|
|
846
|
+
// minute, and the peer goes back to "calls in". Its next call tries once
|
|
847
|
+
// more. A row that answered has already left calledBack above.
|
|
848
|
+
const at = `${peer.addr}:${peer.port}`;
|
|
849
|
+
if (calledBack.has(at)) {
|
|
850
|
+
calledBack.delete(at);
|
|
851
|
+
manual.delete(at);
|
|
852
|
+
learned.delete(at);
|
|
853
|
+
lastRound.delete(peer.fp);
|
|
854
|
+
onChange?.();
|
|
855
|
+
}
|
|
748
856
|
return [];
|
|
749
857
|
} finally {
|
|
750
858
|
conn?.sock?.destroy();
|
package/src/server/lan-reach.mjs
CHANGED
|
@@ -31,25 +31,35 @@
|
|
|
31
31
|
// suite can run it on any platform. That is the same division lan-sync.mjs and
|
|
32
32
|
// lan-socket.mjs already keep, for the same reason.
|
|
33
33
|
|
|
34
|
-
/** What the probe asks Windows for.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
34
|
+
/** What the probe asks Windows for. `Get-NetConnectionProfile` and
|
|
35
|
+
* `Get-NetFirewallProfile` answer for an ordinary user; `Get-NetFirewallPortFilter`
|
|
36
|
+
* returns "Access is denied", which is why the rule this looks for is scoped
|
|
37
|
+
* to a PROGRAM rather than a port — also the only kind that keeps working,
|
|
38
|
+
* since the sync listener's port is ephemeral and different after every
|
|
39
|
+
* restart. `Get-NetFirewallApplicationFilter` answers on many machines but is
|
|
40
|
+
* denied on some domain-managed ones, so it is wrapped and its failure carried
|
|
41
|
+
* through as `rulesReadable=$false` rather than silently read as "no rule". */
|
|
41
42
|
export const PROBE_PS = [
|
|
42
43
|
"$ErrorActionPreference='SilentlyContinue'",
|
|
43
44
|
"$nets = @(Get-NetConnectionProfile | ForEach-Object { [pscustomobject]@{ alias=$_.InterfaceAlias; category=[string]$_.NetworkCategory; v4=[string]$_.IPv4Connectivity } })",
|
|
44
45
|
"$profiles = @(Get-NetFirewallProfile | ForEach-Object { [pscustomobject]@{ name=[string]$_.Name; enabled=[bool]$_.Enabled } })",
|
|
45
|
-
|
|
46
|
+
// AN EMPTY LIST HAS TWO MEANINGS, AND THEY ARE OPPOSITE. `Get-NetFirewallApplicationFilter`
|
|
47
|
+
// answers for an ordinary user on some machines and throws "Access is denied"
|
|
48
|
+
// on others (a domain-managed box, measured). With errors silenced it came
|
|
49
|
+
// back empty either way — and empty was read as "no rule", so a machine whose
|
|
50
|
+
// rule the deck simply could not see was told, wrongly, that it had none, and
|
|
51
|
+
// handed a command that added one more every time it was run. Wrapped so the
|
|
52
|
+
// throw is caught and reported as `rulesReadable=$false`: the difference
|
|
53
|
+
// between "there is no rule" and "I was not allowed to look".
|
|
54
|
+
"$rulesReadable=$true",
|
|
55
|
+
"try { $rules = @(Get-NetFirewallApplicationFilter -ErrorAction Stop | Where-Object { $_.Program -eq $env:CCDECK_EXE } | ForEach-Object { $_ | Get-NetFirewallRule } | ForEach-Object { [pscustomobject]@{ direction=[string]$_.Direction; action=[string]$_.Action; enabled=[bool]$_.Enabled; profile=[string]$_.Profile } }) } catch { $rulesReadable=$false; $rules=@() }",
|
|
46
56
|
// WHICH INTERFACE IS THE LAN, asked of the routing table rather than guessed
|
|
47
57
|
// from a name. The beacon goes to 255.255.255.255, so the interface that
|
|
48
58
|
// carries the broadcast IS the one this feature lives or dies on — and on a
|
|
49
59
|
// machine with Tailscale beside wifi the two have different categories, so
|
|
50
60
|
// picking the wrong one reports Private and clears a machine that is blocked.
|
|
51
61
|
"$bcast = [string](Find-NetRoute -RemoteIPAddress 255.255.255.255 | Select-Object -First 1 -ExpandProperty InterfaceAlias)",
|
|
52
|
-
"[pscustomobject]@{ nets=$nets; profiles=$profiles; rules=$rules; bcast=$bcast } | ConvertTo-Json -Depth 4 -Compress",
|
|
62
|
+
"[pscustomobject]@{ nets=$nets; profiles=$profiles; rules=$rules; rulesReadable=$rulesReadable; bcast=$bcast } | ConvertTo-Json -Depth 4 -Compress",
|
|
53
63
|
].join("; ");
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -88,6 +98,9 @@ export function readProbe(stdout) {
|
|
|
88
98
|
enabled: r.enabled === true,
|
|
89
99
|
profile: typeof r.profile === "string" ? r.profile : "",
|
|
90
100
|
})),
|
|
101
|
+
// True unless the probe said it could not read the rules — and true for an
|
|
102
|
+
// older probe that did not report it, which kept working as it always did.
|
|
103
|
+
rulesReadable: raw.rulesReadable !== false,
|
|
91
104
|
bcast: typeof raw.bcast === "string" ? raw.bcast.trim() : "",
|
|
92
105
|
};
|
|
93
106
|
}
|
|
@@ -479,6 +492,16 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
|
|
|
479
492
|
if (ruleCovers(probe.rules, name)) {
|
|
480
493
|
return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
|
|
481
494
|
}
|
|
495
|
+
// COULD NOT READ THE RULES, SO CANNOT SAY THERE IS NONE. An empty list here
|
|
496
|
+
// is "Access is denied", not "no rule" (see PROBE_PS), so claiming a missing
|
|
497
|
+
// rule would be a guess dressed as a read — and the command that guess hands
|
|
498
|
+
// over adds a duplicate rule on a machine that may already be covered. On a
|
|
499
|
+
// Public network the fix is the category, which was read reliably, so that
|
|
500
|
+
// verdict still stands; anywhere else the honest answer is the measurement,
|
|
501
|
+
// which `refreshReach` falls back to when this returns null.
|
|
502
|
+
// Only an EMPTY list is ambiguous — a rule in hand was plainly readable,
|
|
503
|
+
// whatever the flag says.
|
|
504
|
+
if (!probe.rulesReadable && probe.rules.length === 0 && name !== "Public") return null;
|
|
482
505
|
return {
|
|
483
506
|
blocked: true,
|
|
484
507
|
why: "no inbound rule",
|
|
@@ -967,6 +967,15 @@ export function createSyncServer({
|
|
|
967
967
|
if (!frame) return refuse("a sealed frame did not open");
|
|
968
968
|
handlers?.(frame, {
|
|
969
969
|
sock, peerFp, key, sealed: !!chan,
|
|
970
|
+
// Where this deck dials the caller BACK. A deck that only ever calls in
|
|
971
|
+
// is one this deck holds no address for, so it could receive nothing —
|
|
972
|
+
// accounts move only toward the deck that dials (see roundWith). The
|
|
973
|
+
// address the caller connected from, and the port it said it listens
|
|
974
|
+
// on, are a dialable pair the engine can add so the next round reaches
|
|
975
|
+
// it. `peerAddr` is the source of this very connection; `peerPort` came
|
|
976
|
+
// from the hello, not the ephemeral source port.
|
|
977
|
+
peerAddr: from(sock),
|
|
978
|
+
peerPort,
|
|
970
979
|
send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
|
|
971
980
|
});
|
|
972
981
|
}, refuse));
|