ccdeck 3.23.3 → 3.23.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/dist/web/assets/{BrowserWatchModal-Cro433q6.js → BrowserWatchModal-B8m4UFih.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-D1sRtUUY.js → UsageHistoryModal-DMrkxas9.js} +1 -1
- package/dist/web/assets/{index-cRw55wBV.css → index-BzFGrdm1.css} +1 -1
- package/dist/web/assets/{index-gPPlJnr8.js → index-D3oqs0dv.js} +41 -41
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +1 -1
- package/src/server/index.mjs +113 -9
- package/src/server/lan-engine.mjs +19 -0
- package/src/server/lan-reach.mjs +193 -14
package/dist/web/index.html
CHANGED
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
45
45
|
})();
|
|
46
46
|
</script>
|
|
47
|
-
<script type="module" crossorigin src="/assets/index-
|
|
48
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
47
|
+
<script type="module" crossorigin src="/assets/index-D3oqs0dv.js"></script>
|
|
48
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BzFGrdm1.css">
|
|
49
49
|
</head>
|
|
50
50
|
<body>
|
|
51
51
|
<div id="root"></div>
|
package/package.json
CHANGED
package/release-notes.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"the release whose version is in neither place. Any key opening with // is a",
|
|
40
40
|
"note to the author and never a release."
|
|
41
41
|
],
|
|
42
|
-
"//nothing-to-say": ["3.23.1", "3.23.2", "3.23.3"],
|
|
42
|
+
"//nothing-to-say": ["3.23.1", "3.23.2", "3.23.3", "3.23.4"],
|
|
43
43
|
"3.23.0": [
|
|
44
44
|
{
|
|
45
45
|
"title": "🧭 The accounts column leads with accounts",
|
package/src/server/index.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import { createPresence } from "./presence.mjs";
|
|
|
37
37
|
import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
|
|
38
38
|
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
39
39
|
import { aboutThisDeck } from "./lan-about.mjs";
|
|
40
|
-
import { PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reachability, readProbe, readUfw } from "./lan-reach.mjs";
|
|
40
|
+
import { MAC_FW, PROBE_PS, UFW_CONF, UFW_DEFAULTS, isActive, localAliases, reachability, readMacProbe, readProbe, readUfw, silentInbound } from "./lan-reach.mjs";
|
|
41
41
|
import { run } from "./exec.mjs";
|
|
42
42
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
43
43
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
@@ -3727,6 +3727,10 @@ async function handlePrefsWrite(req, res) {
|
|
|
3727
3727
|
// so turning the switch off in the panel really does stop the sockets rather
|
|
3728
3728
|
// than only changing what the panel says.
|
|
3729
3729
|
await applyLanPrefs();
|
|
3730
|
+
// Somebody has just switched this on and is watching the panel for the answer
|
|
3731
|
+
// to one question: is anything going to turn up. Whatever the probe said
|
|
3732
|
+
// before is about a deck whose sockets were down — see forgetReach.
|
|
3733
|
+
if (body.lan?.enabled === true) forgetReach();
|
|
3730
3734
|
return send(res, 200, prefsPayload());
|
|
3731
3735
|
}
|
|
3732
3736
|
|
|
@@ -3914,6 +3918,9 @@ const lanEngine = createEngine({
|
|
|
3914
3918
|
onPort: async port => {
|
|
3915
3919
|
try { _prefs = await writePrefs({ lan: { port } }); }
|
|
3916
3920
|
catch { /* the address field still works this session; next start re-pins */ }
|
|
3921
|
+
// The Linux fix lines name this number, so a verdict taken before the
|
|
3922
|
+
// listener had one is missing half of them — see forgetReach.
|
|
3923
|
+
forgetReach();
|
|
3917
3924
|
},
|
|
3918
3925
|
// A deck was accepted or unpaired. Written straight through, because the
|
|
3919
3926
|
// trusted list is the whole of who this deck will talk to and a list that
|
|
@@ -4100,21 +4107,118 @@ async function probeLinux() {
|
|
|
4100
4107
|
};
|
|
4101
4108
|
}
|
|
4102
4109
|
|
|
4110
|
+
/**
|
|
4111
|
+
* Forget what the last probe said, so the next poll measures rather than
|
|
4112
|
+
* answering out of the five-minute cache.
|
|
4113
|
+
*
|
|
4114
|
+
* TWO MOMENTS EARN IT, and both are moments where the held answer is about a
|
|
4115
|
+
* machine in a different state from the one being asked about.
|
|
4116
|
+
*
|
|
4117
|
+
* A SWITCH-ON is the one somebody is watching. The panel starts polling every
|
|
4118
|
+
* five seconds the instant it goes on, and without this the first several
|
|
4119
|
+
* minutes of that are a verdict taken while the sockets were down — which is
|
|
4120
|
+
* the exact question they turned it on to have answered. Five minutes is the
|
|
4121
|
+
* right staleness for a firewall nobody is touching and the wrong one for the
|
|
4122
|
+
* press that starts the feature.
|
|
4123
|
+
*
|
|
4124
|
+
* THE SYNC PORT LANDING changes what the answer CONTAINS on Linux: the fix
|
|
4125
|
+
* lines name a TCP port (see linuxFixSteps), and a verdict taken before the
|
|
4126
|
+
* listener had one offers the discovery line alone — half an answer, pinned for
|
|
4127
|
+
* five minutes, on the machine that most needs the other half.
|
|
4128
|
+
*/
|
|
4129
|
+
function forgetReach() { reachAt = 0; }
|
|
4130
|
+
|
|
4131
|
+
/**
|
|
4132
|
+
* What the macOS application firewall says about this deck.
|
|
4133
|
+
*
|
|
4134
|
+
* Three reads, none of them privileged — measured on macOS 26.6, where all of
|
|
4135
|
+
* them answer an ordinary user with exit 0 and no password. See the macos block
|
|
4136
|
+
* in lan-reach.mjs for why this platform can be asked the exact question about
|
|
4137
|
+
* its own binary while Linux has to reason from a default policy.
|
|
4138
|
+
*
|
|
4139
|
+
* A call that fails comes back as an empty string rather than as a throw, which
|
|
4140
|
+
* readMacProbe turns into a null field and macReach turns into silence: a Mac
|
|
4141
|
+
* that will not answer is a Mac this says nothing about.
|
|
4142
|
+
*/
|
|
4143
|
+
async function probeMac() {
|
|
4144
|
+
const ask = args => run(MAC_FW, args, { timeout: 5_000 }).then(r => (r?.ok ? r.stdout : "")).catch(() => "");
|
|
4145
|
+
const [globalState, blockAll, app] = await Promise.all([
|
|
4146
|
+
ask(["--getglobalstate"]),
|
|
4147
|
+
ask(["--getblockall"]),
|
|
4148
|
+
// The binary the LISTENER runs as, which is this process's own executable —
|
|
4149
|
+
// the application firewall's rules are about a program, never about a port.
|
|
4150
|
+
ask(["--getappblocked", process.execPath]),
|
|
4151
|
+
]);
|
|
4152
|
+
return readMacProbe({ global: globalState, blockAll, app });
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
/** How recently a beacon has to have arrived to count as a machine this deck
|
|
4156
|
+
* can hear. Three announce intervals, which is the same window the panel calls
|
|
4157
|
+
* `now` — one dropped broadcast must not retract the claim. */
|
|
4158
|
+
const HEARD_MS = 90_000;
|
|
4159
|
+
|
|
4160
|
+
/**
|
|
4161
|
+
* The verdict for a machine no probe could speak about — see silentInbound.
|
|
4162
|
+
*
|
|
4163
|
+
* BOTH LISTS ARE COUNTED. `strangers` is already one row per machine, and a
|
|
4164
|
+
* deck LEAVES it for `peers` the moment somebody pairs with it — so counting
|
|
4165
|
+
* strangers alone would make a deck that has paired with everything it can hear
|
|
4166
|
+
* look like a deck that hears nobody, which is the one state this must not
|
|
4167
|
+
* confuse with being blocked.
|
|
4168
|
+
*/
|
|
4169
|
+
function measuredReach() {
|
|
4170
|
+
const st = lanEngine.status();
|
|
4171
|
+
const at = Date.now();
|
|
4172
|
+
const fresh = p => typeof p?.lastSeen === "number" && at - p.lastSeen < HEARD_MS;
|
|
4173
|
+
const heard = (st.strangers?.length ?? 0) + (st.peers ?? []).filter(fresh).length;
|
|
4174
|
+
return silentInbound({ heard, listeningSince: st.listeningSince, inbound: st.inboundAt, now: at });
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4103
4177
|
function refreshReach() {
|
|
4104
|
-
if (reachBusy
|
|
4178
|
+
if (reachBusy) return;
|
|
4105
4179
|
if (reachAt && Date.now() - reachAt < REACH_MS) return;
|
|
4106
4180
|
reachBusy = true;
|
|
4107
4181
|
if (process.platform === "linux") {
|
|
4108
4182
|
probeLinux().then(linux => {
|
|
4109
|
-
|
|
4183
|
+
// A read of the real configuration beats an inference from silence, so
|
|
4184
|
+
// the measurement only speaks where the probe had no opinion at all.
|
|
4185
|
+
reachSaid = reachability({ platform: "linux", linux, inbound: lanEngine.status().inboundAt })
|
|
4186
|
+
?? measuredReach();
|
|
4187
|
+
}).catch(() => {
|
|
4188
|
+
reachSaid = measuredReach();
|
|
4189
|
+
}).finally(() => {
|
|
4190
|
+
reachAt = Date.now();
|
|
4191
|
+
reachBusy = false;
|
|
4192
|
+
});
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
if (process.platform === "darwin") {
|
|
4196
|
+
probeMac().then(mac => {
|
|
4197
|
+
reachSaid = reachability({
|
|
4198
|
+
platform: "darwin",
|
|
4199
|
+
mac,
|
|
4200
|
+
// The same path the probe asked about, so the lines somebody pastes name
|
|
4201
|
+
// the binary the verdict was taken on.
|
|
4202
|
+
exePath: process.execPath,
|
|
4203
|
+
inbound: lanEngine.status().inboundAt,
|
|
4204
|
+
}) ?? measuredReach();
|
|
4110
4205
|
}).catch(() => {
|
|
4111
|
-
reachSaid =
|
|
4206
|
+
reachSaid = measuredReach();
|
|
4112
4207
|
}).finally(() => {
|
|
4113
4208
|
reachAt = Date.now();
|
|
4114
4209
|
reachBusy = false;
|
|
4115
4210
|
});
|
|
4116
4211
|
return;
|
|
4117
4212
|
}
|
|
4213
|
+
// EVERY OTHER PLATFORM, which used to return before reaching any of this and
|
|
4214
|
+
// therefore said nothing forever. FreeBSD, an unrecognised Linux, anything
|
|
4215
|
+
// node runs on: no probe to run, and the measurement needs none.
|
|
4216
|
+
if (process.platform !== "win32") {
|
|
4217
|
+
reachSaid = measuredReach();
|
|
4218
|
+
reachAt = Date.now();
|
|
4219
|
+
reachBusy = false;
|
|
4220
|
+
return;
|
|
4221
|
+
}
|
|
4118
4222
|
run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PROBE_PS], {
|
|
4119
4223
|
// The program path goes through the environment rather than into the
|
|
4120
4224
|
// script, so a path with a quote in it cannot close the string it sits in.
|
|
@@ -4127,12 +4231,12 @@ function refreshReach() {
|
|
|
4127
4231
|
aliases: localAliases(networkInterfaces()),
|
|
4128
4232
|
exePath: process.execPath,
|
|
4129
4233
|
inbound: lanEngine.status().inboundAt,
|
|
4130
|
-
});
|
|
4234
|
+
}) ?? measuredReach();
|
|
4131
4235
|
}).catch(() => {
|
|
4132
|
-
// A probe that did not run
|
|
4133
|
-
//
|
|
4134
|
-
// nobody asked for it.
|
|
4135
|
-
reachSaid =
|
|
4236
|
+
// A probe that did not run is exactly the machine silentInbound was written
|
|
4237
|
+
// for: nothing can be asked, so what is left is what was measured. It is
|
|
4238
|
+
// never an error the panel shows — nobody asked for it.
|
|
4239
|
+
reachSaid = measuredReach();
|
|
4136
4240
|
}).finally(() => {
|
|
4137
4241
|
reachAt = Date.now();
|
|
4138
4242
|
reachBusy = false;
|
|
@@ -337,6 +337,17 @@ export function createEngine({
|
|
|
337
337
|
*/
|
|
338
338
|
let inboundAt = null;
|
|
339
339
|
|
|
340
|
+
/** When this deck's listener came up, or null while it is down.
|
|
341
|
+
*
|
|
342
|
+
* THE OTHER HALF OF `inboundAt`. On its own, "nothing has ever connected in"
|
|
343
|
+
* says nothing: a deck that started four seconds ago has the same null as one
|
|
344
|
+
* that has been listening all afternoon while the network talked around it.
|
|
345
|
+
* What makes the silence evidence is how long it has gone on for, and that is
|
|
346
|
+
* a number only the engine holds. See silentInbound in lan-reach.mjs, which
|
|
347
|
+
* is the one verdict in this feature that works on a platform nothing can be
|
|
348
|
+
* asked about. */
|
|
349
|
+
let listeningSince = null;
|
|
350
|
+
|
|
340
351
|
/** This deck's accounts in the shape the rules want. Read through the same
|
|
341
352
|
* function the panel uses, so a row can never be alive here and dead there. */
|
|
342
353
|
const localAccounts = async () => {
|
|
@@ -838,6 +849,9 @@ export function createEngine({
|
|
|
838
849
|
throw err;
|
|
839
850
|
}
|
|
840
851
|
stalled = null;
|
|
852
|
+
// From here the socket is accepting, so this is the moment the silence
|
|
853
|
+
// starts being about the network rather than about a deck still starting.
|
|
854
|
+
listeningSince = now();
|
|
841
855
|
if (port !== cfg.port) onPort?.(port);
|
|
842
856
|
beacon = createBeacon({
|
|
843
857
|
port, name: cfg.name, fp: identity.fp,
|
|
@@ -1211,6 +1225,8 @@ export function createEngine({
|
|
|
1211
1225
|
// on a deck nobody has dialled yet, which is not the same as blocked
|
|
1212
1226
|
// and is drawn as neither — see inboundAt and lan-reach.mjs.
|
|
1213
1227
|
inboundAt,
|
|
1228
|
+
// How long that null has been true for — see listeningSince.
|
|
1229
|
+
listeningSince,
|
|
1214
1230
|
addrs: beacon ? localAddresses() : [],
|
|
1215
1231
|
shared: [...cfg.shared],
|
|
1216
1232
|
// The token this deck is offering, if any. Drawn as the one thing to do
|
|
@@ -1323,6 +1339,9 @@ export function createEngine({
|
|
|
1323
1339
|
server?.stop();
|
|
1324
1340
|
beacon = null;
|
|
1325
1341
|
server = null;
|
|
1342
|
+
// Nothing is listening, so nothing is being silent AT anybody. Leaving
|
|
1343
|
+
// this set would have the next start measure its quiet from the last one.
|
|
1344
|
+
listeningSince = null;
|
|
1326
1345
|
},
|
|
1327
1346
|
};
|
|
1328
1347
|
}
|
package/src/server/lan-reach.mjs
CHANGED
|
@@ -296,30 +296,152 @@ export function linuxReach({ ufw = null, firewalld = false, inbound = null, sync
|
|
|
296
296
|
};
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
// ── macos ───────────────────────────────────────────────────────────────────
|
|
300
|
+
//
|
|
301
|
+
// THE REVERSAL. This module used to say macOS was deliberately silent: the
|
|
302
|
+
// firewall ships off, and when it is on it asks the person at the keyboard the
|
|
303
|
+
// first time a program listens — so the answer was either "nothing is in the
|
|
304
|
+
// way" or "somebody was shown a dialog and pressed a button".
|
|
305
|
+
//
|
|
306
|
+
// The second half of that is what gave way. A deck started from a terminal, or
|
|
307
|
+
// by npx, or by a launch agent, is not always a program somebody is sitting in
|
|
308
|
+
// front of when the question is asked, and a dialog nobody answers is a denial
|
|
309
|
+
// that looks exactly like an empty list. And the answer IS readable. Measured
|
|
310
|
+
// on macOS 26.6, as an ordinary user, no password and exit 0 on all four:
|
|
311
|
+
// `--getglobalstate`, `--getblockall`, `--getstealthmode`, and
|
|
312
|
+
// `--getappblocked <path>`.
|
|
313
|
+
//
|
|
314
|
+
// WHICH MAKES THIS THE BEST-INFORMED OF THE THREE PLATFORMS, not the worst.
|
|
315
|
+
// ufw will not show its rules to anybody but root, so the Linux verdict reasons
|
|
316
|
+
// from a default policy and says out loud that it could not check. This one can
|
|
317
|
+
// ask the exact question — is an incoming connection to THIS binary permitted —
|
|
318
|
+
// about the binary the listener actually runs as. So there is no `unsure` here,
|
|
319
|
+
// because there is nothing it could not look at.
|
|
320
|
+
//
|
|
321
|
+
// WHAT IS NOT EVIDENCE, and the trap this nearly walked into: with the firewall
|
|
322
|
+
// OFF, `--getappblocked` answers "is permitted" for every path handed to it,
|
|
323
|
+
// including one that does not exist. Measured, and it is the reason the order
|
|
324
|
+
// in macReach is load-bearing rather than tidy — the per-app answer means
|
|
325
|
+
// nothing at all until the global state says the firewall is on.
|
|
326
|
+
//
|
|
327
|
+
// STEALTH MODE IS READ AND NOT ACTED ON. It drops unsolicited probes to ports
|
|
328
|
+
// nothing is listening on, which is not this: a deck that is listening and
|
|
329
|
+
// allowed still answers. Blaming it would send somebody to change a setting
|
|
330
|
+
// that was never in the way.
|
|
331
|
+
|
|
332
|
+
/** The application firewall's own tool. Not on PATH, and the full path is what
|
|
333
|
+
* goes in the lines somebody pastes, so it is written once. */
|
|
334
|
+
export const MAC_FW = "/usr/libexec/ApplicationFirewall/socketfilterfw";
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* What the three reads said, as a verdict-shaped thing.
|
|
338
|
+
*
|
|
339
|
+
* Every field can come back null, which is this file's word for "the machine
|
|
340
|
+
* did not answer that" — a locked-down build, a future macOS that renamed the
|
|
341
|
+
* flag, a tool that is not there at all. Null travels to macReach and comes out
|
|
342
|
+
* as silence rather than as a guess.
|
|
343
|
+
*/
|
|
344
|
+
export function readMacProbe({ global: globalState, blockAll, app } = {}) {
|
|
345
|
+
// "Firewall is disabled. (State = 0)" / "Firewall is enabled. (State = 1)".
|
|
346
|
+
// The number is read rather than the adjective: it is the stable half of the
|
|
347
|
+
// sentence, and 2 is a third state that says enabled in words.
|
|
348
|
+
const state = /State\s*=\s*(\d+)/i.exec(String(globalState ?? ""))?.[1];
|
|
349
|
+
return {
|
|
350
|
+
on: state == null ? null : Number(state) > 0,
|
|
351
|
+
// "Firewall has block all state set to enabled." — the setting that turns
|
|
352
|
+
// every allowance off at once, including one this deck already has.
|
|
353
|
+
blockAll: /block all state set to enabled/i.test(String(blockAll ?? "")),
|
|
354
|
+
// "Incoming connection to <path> is permitted." / "... is blocked."
|
|
355
|
+
appBlocked: /is blocked/i.test(String(app ?? "")) ? true
|
|
356
|
+
: /is permitted/i.test(String(app ?? "")) ? false
|
|
357
|
+
: null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The lines that open the path, as text.
|
|
363
|
+
*
|
|
364
|
+
* SCOPED TO THE PROGRAM, like the Windows rule and unlike the Linux ones: the
|
|
365
|
+
* application firewall has no notion of a port, which is what makes it the
|
|
366
|
+
* right shape here — the sync listener's port is a different number after some
|
|
367
|
+
* restarts, and a rule about the binary survives that.
|
|
368
|
+
*
|
|
369
|
+
* `--add` before `--unblockapp` because a binary the firewall has never heard
|
|
370
|
+
* of cannot be unblocked; adding one that is already listed changes nothing,
|
|
371
|
+
* so the pair is safe to paste twice.
|
|
372
|
+
*/
|
|
373
|
+
export function macFixSteps({ exePath, blockAll = false } = {}) {
|
|
374
|
+
const steps = [];
|
|
375
|
+
// First, because while it is on nothing else in this list has any effect.
|
|
376
|
+
if (blockAll) steps.push(`sudo ${MAC_FW} --setblockall off`);
|
|
377
|
+
steps.push(`sudo ${MAC_FW} --add "${exePath}"`);
|
|
378
|
+
steps.push(`sudo ${MAC_FW} --unblockapp "${exePath}"`);
|
|
379
|
+
return steps;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Whether other decks can reach this Mac, from what the application firewall
|
|
384
|
+
* says about this deck's own binary.
|
|
385
|
+
*
|
|
386
|
+
* Null for anything it could not read, and for the ordinary machine whose
|
|
387
|
+
* firewall is simply off — see the block above for why `appBlocked` is not
|
|
388
|
+
* consulted until `on` is true.
|
|
389
|
+
*/
|
|
390
|
+
export function macReach({ probe = null, exePath = "", inbound = null } = {}) {
|
|
391
|
+
// MEASURED BEATS READ, here as on the other two: a connection from another
|
|
392
|
+
// machine has arrived, so the path is open whatever the settings say.
|
|
393
|
+
if (inbound) return { blocked: false, why: "inbound seen", category: "", alias: "" };
|
|
394
|
+
if (!probe || probe.on == null) return null;
|
|
395
|
+
if (!probe.on) return { blocked: false, why: "firewall off", category: "", alias: "" };
|
|
396
|
+
// Both halves of the sentence, because the asymmetry is the confusing part:
|
|
397
|
+
// the other deck may already show this machine's name, which reads as a
|
|
398
|
+
// working connection and is not one.
|
|
399
|
+
const heard = " They can still hear it — that is why one of them may already show this machine.";
|
|
400
|
+
if (probe.blockAll) {
|
|
401
|
+
return {
|
|
402
|
+
blocked: true,
|
|
403
|
+
why: "block all incoming",
|
|
404
|
+
category: "",
|
|
405
|
+
alias: "",
|
|
406
|
+
shell: "sh",
|
|
407
|
+
text: "macOS is set to block all incoming connections, so other decks cannot reach this one." + heard,
|
|
408
|
+
steps: macFixSteps({ exePath, blockAll: true }),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (probe.appBlocked === true) {
|
|
412
|
+
return {
|
|
413
|
+
blocked: true,
|
|
414
|
+
why: "app blocked",
|
|
415
|
+
category: "",
|
|
416
|
+
alias: "",
|
|
417
|
+
shell: "sh",
|
|
418
|
+
text: "The macOS firewall is refusing incoming connections to this deck, so other decks cannot reach it." + heard,
|
|
419
|
+
steps: macFixSteps({ exePath }),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (probe.appBlocked === false) return { blocked: false, why: "app permitted", category: "", alias: "" };
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
299
426
|
/**
|
|
300
427
|
* Whether other decks can reach this one, and what to do when they cannot.
|
|
301
428
|
*
|
|
302
429
|
* `null` means "no opinion", and it is the answer for every machine this cannot
|
|
303
|
-
* speak about: anything that is
|
|
304
|
-
* did not come back. Silence rather than a hedge — a panel line that
|
|
305
|
-
* "possibly" about a thing it did not measure is worse than no line.
|
|
306
|
-
*
|
|
307
|
-
* macOS is deliberately among the silent ones. Its firewall is off by default
|
|
308
|
-
* and, when on, asks the person at the keyboard the first time a program
|
|
309
|
-
* listens — so the answer is either "nothing is in the way" or "somebody was
|
|
310
|
-
* shown a dialog and pressed a button", and neither is a thing to instruct
|
|
311
|
-
* anybody about.
|
|
430
|
+
* speak about: anything that is none of the three platforms below, and any
|
|
431
|
+
* probe that did not come back. Silence rather than a hedge — a panel line that
|
|
432
|
+
* says "possibly" about a thing it did not measure is worse than no line.
|
|
312
433
|
*
|
|
313
434
|
* `blocked: false` is a real finding and is worth returning: it lets the panel
|
|
314
435
|
* stop blaming the firewall for an empty list and say the other thing instead,
|
|
315
436
|
* which is that nobody else is running.
|
|
316
437
|
*/
|
|
317
|
-
export function reachability({ platform, probe, aliases = [], exePath = "", inbound = null, linux = null } = {}) {
|
|
318
|
-
//
|
|
319
|
-
// why
|
|
320
|
-
// input
|
|
321
|
-
// outranks whatever
|
|
438
|
+
export function reachability({ platform, probe, aliases = [], exePath = "", inbound = null, linux = null, mac = null } = {}) {
|
|
439
|
+
// Three platforms, three entirely different reads — see each block above for
|
|
440
|
+
// why none of them can be written in another's shape. `inbound` is the one
|
|
441
|
+
// input all of them share: it is a measurement rather than a read, so it
|
|
442
|
+
// outranks whatever any platform's configuration says.
|
|
322
443
|
if (platform === "linux") return linux ? linuxReach({ ...linux, inbound }) : null;
|
|
444
|
+
if (platform === "darwin") return mac ? macReach({ probe: mac, exePath, inbound }) : null;
|
|
323
445
|
if (platform !== "win32") return null;
|
|
324
446
|
if (!probe) return null;
|
|
325
447
|
// The broadcast route first, then the interfaces the deck holds an address
|
|
@@ -378,6 +500,63 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
|
|
|
378
500
|
* the first try if the invite is minted at the other end and pasted here. Same
|
|
379
501
|
* two controls, opposite order, and only one of the orders works.
|
|
380
502
|
*/
|
|
503
|
+
// ── the machine nothing can be asked about ──────────────────────────────────
|
|
504
|
+
//
|
|
505
|
+
// Every block above depends on the operating system answering a question.
|
|
506
|
+
// FreeBSD has nobody to ask; a Linux box running plain nftables has no ufw.conf
|
|
507
|
+
// to read; a Windows probe comes back empty for a dozen policy reasons; a
|
|
508
|
+
// hardened Mac can refuse the tool. All of them arrive at `reachability` as
|
|
509
|
+
// null — which is honest, and no use at all to somebody watching an empty list.
|
|
510
|
+
//
|
|
511
|
+
// WHAT IS MEASURABLE ON ANY OF THEM. A beacon ARRIVING proves this machine
|
|
512
|
+
// hears the network. `inboundAt` still null proves nothing has ever connected
|
|
513
|
+
// in. Those two facts together are the exact shape of a blocked inbound path,
|
|
514
|
+
// and neither of them asks the OS anything.
|
|
515
|
+
//
|
|
516
|
+
// SO IT IS A LAST RESORT, NOT A SECOND OPINION. It speaks only where a platform
|
|
517
|
+
// verdict said nothing: a read of the actual configuration beats an inference
|
|
518
|
+
// from silence every time, including when the read says the firewall is off and
|
|
519
|
+
// the silence has some other cause this cannot name.
|
|
520
|
+
|
|
521
|
+
/** How long this deck has to have been listening — hearing other machines and
|
|
522
|
+
* receiving nothing — before the silence is worth reporting.
|
|
523
|
+
*
|
|
524
|
+
* Deliberately many times over the interval anything would have dialled on: a
|
|
525
|
+
* paired deck runs a round every SYNC_MS (60s) and a stranger that hears this
|
|
526
|
+
* one asks within ASKING_MS (8s), autoAsk being on by default. Five minutes is
|
|
527
|
+
* therefore dozens of missed chances rather than one — and the cost of getting
|
|
528
|
+
* this wrong is telling somebody their network is broken when it is quiet. */
|
|
529
|
+
export const QUIET_MS = 5 * 60_000;
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The verdict for a machine this file cannot read: measured, never read.
|
|
533
|
+
*
|
|
534
|
+
* WHAT IT REFUSES TO SAY. It names no firewall and offers no command, because
|
|
535
|
+
* it looked at no configuration and would be guessing at both. Everything it
|
|
536
|
+
* has is in the one sentence it returns, and the ways out the panel draws under
|
|
537
|
+
* it need no rule on this machine at all.
|
|
538
|
+
*/
|
|
539
|
+
export function silentInbound({ heard = 0, listeningSince = null, inbound = null, now = Date.now() } = {}) {
|
|
540
|
+
// Something got in, so the path is open — the same measurement that outranks
|
|
541
|
+
// a configuration read everywhere else in this file.
|
|
542
|
+
if (inbound) return null;
|
|
543
|
+
// Nobody is out there. "Nothing has arrived" and "nothing can arrive" are the
|
|
544
|
+
// two facts this whole module exists to tell apart, and with no beacon in
|
|
545
|
+
// hand this one is the first.
|
|
546
|
+
if (!heard) return null;
|
|
547
|
+
if (!listeningSince) return null;
|
|
548
|
+
const listening = now - listeningSince;
|
|
549
|
+
if (listening < QUIET_MS) return null;
|
|
550
|
+
const mins = Math.round(listening / 60_000);
|
|
551
|
+
return {
|
|
552
|
+
blocked: true,
|
|
553
|
+
why: "heard them, nothing got in",
|
|
554
|
+
category: "",
|
|
555
|
+
alias: "",
|
|
556
|
+
text: `This deck hears ${heard === 1 ? "another deck" : `${heard} other decks`} on the network and, in the ${mins} minutes it has been listening, nothing has ever connected to it — which is what a blocked inbound path looks like from this side. This machine cannot be asked which firewall it runs, so there is no line to paste.`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
381
560
|
export function workaround(blocked) {
|
|
382
561
|
if (!blocked) return null;
|
|
383
562
|
return {
|