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