ccdeck 3.23.0 → 3.23.2
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-DD6srgsk.js → BrowserWatchModal-plr3Bmmv.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-zExZpRWU.js → UsageHistoryModal-DEZjEWrd.js} +1 -1
- package/dist/web/assets/{index-Dsca2TwS.js → index-DuAcNsxl.js} +46 -46
- package/dist/web/assets/index-cRw55wBV.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +2 -2
- package/src/server/deck-prefs.mjs +14 -10
- package/src/server/index.mjs +42 -2
- package/src/server/lan-engine.mjs +42 -0
- package/src/server/lan-reach.mjs +136 -4
- package/src/server/lan-socket.mjs +18 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +0 -1
|
@@ -114,6 +114,24 @@ export function localAddresses(faces = networkInterfaces()) {
|
|
|
114
114
|
return out;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Did this connection come from a DIFFERENT computer?
|
|
119
|
+
*
|
|
120
|
+
* The question behind "can other decks reach this one": a socket accepted from
|
|
121
|
+
* loopback, or from one of this machine's own addresses, is the second deck on
|
|
122
|
+
* this computer talking to the first — which happens on every developer machine
|
|
123
|
+
* and proves nothing at all about the network. Both spellings of loopback are
|
|
124
|
+
* named because both arrive: `127.0.0.1` from a deck that dialled an address
|
|
125
|
+
* and `::1` from one that dialled a name.
|
|
126
|
+
*
|
|
127
|
+
* Pure and separate from the engine so the case that matters — a connection
|
|
128
|
+
* from somewhere else — can be tested without a second machine.
|
|
129
|
+
*/
|
|
130
|
+
export const anotherMachine = (from, mine = []) => {
|
|
131
|
+
const at = String(from ?? "").replace(/^::ffff:/, "").trim();
|
|
132
|
+
return !!at && at !== "127.0.0.1" && at !== "::1" && !mine.includes(at);
|
|
133
|
+
};
|
|
134
|
+
|
|
117
135
|
/**
|
|
118
136
|
* The accounts a peer's manifest listed, as the panel may keep them.
|
|
119
137
|
*
|
|
@@ -302,6 +320,22 @@ export function createEngine({
|
|
|
302
320
|
* is the evidence, and it is the same kind the beacon gives: a timestamp.
|
|
303
321
|
*/
|
|
304
322
|
const spokeAt = new Map();
|
|
323
|
+
/**
|
|
324
|
+
* When a connection from ANOTHER MACHINE last arrived on the sync listener.
|
|
325
|
+
*
|
|
326
|
+
* The one fact that settles "can other decks reach this one", and the only
|
|
327
|
+
* one on the whole question that is measured rather than reasoned about: a
|
|
328
|
+
* firewall's configuration is read through three different tools on three
|
|
329
|
+
* platforms, one of which (`ufw`) refuses to show its rules to a process
|
|
330
|
+
* that is not root. An accepted socket needs none of that — the packets got
|
|
331
|
+
* in, whatever any rule file says.
|
|
332
|
+
*
|
|
333
|
+
* ANOTHER MACHINE, checked here and not in the socket: a connection from
|
|
334
|
+
* loopback or from one of this machine's own addresses is the second deck on
|
|
335
|
+
* this computer, which proves nothing about the network. The socket does not
|
|
336
|
+
* hold that list; this does.
|
|
337
|
+
*/
|
|
338
|
+
let inboundAt = null;
|
|
305
339
|
|
|
306
340
|
/** This deck's accounts in the shape the rules want. Read through the same
|
|
307
341
|
* function the panel uses, so a row can never be alive here and dead there. */
|
|
@@ -755,6 +789,10 @@ export function createEngine({
|
|
|
755
789
|
server = createSyncServer({
|
|
756
790
|
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
757
791
|
name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
|
|
792
|
+
// See inboundAt. Every connection passes here, including one that goes
|
|
793
|
+
// on to fail the handshake — a stranger who cannot prove anything has
|
|
794
|
+
// still proved the path.
|
|
795
|
+
onInbound: from => { if (anotherMachine(from, localAddresses())) inboundAt = now(); },
|
|
758
796
|
trusted: () => cfg.trusted,
|
|
759
797
|
invite: () => (invite && invite.expiresAt > now() ? invite : null),
|
|
760
798
|
// Somebody used the token. They are pinned, and the token is retired —
|
|
@@ -1169,6 +1207,10 @@ export function createEngine({
|
|
|
1169
1207
|
// deck's field. Null when this machine has no ordinary one, which the
|
|
1170
1208
|
// panel says rather than printing a placeholder.
|
|
1171
1209
|
port: server?.port() ?? null,
|
|
1210
|
+
// When another machine last got a connection through to this one. Null
|
|
1211
|
+
// on a deck nobody has dialled yet, which is not the same as blocked
|
|
1212
|
+
// and is drawn as neither — see inboundAt and lan-reach.mjs.
|
|
1213
|
+
inboundAt,
|
|
1172
1214
|
addrs: beacon ? localAddresses() : [],
|
|
1173
1215
|
shared: [...cfg.shared],
|
|
1174
1216
|
// The token this deck is offering, if any. Drawn as the one thing to do
|
package/src/server/lan-reach.mjs
CHANGED
|
@@ -183,19 +183,143 @@ export function fixSteps({ category, alias, exePath }) {
|
|
|
183
183
|
return steps;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// ── linux ───────────────────────────────────────────────────────────────────
|
|
187
|
+
//
|
|
188
|
+
// THE SAME FAILURE, ONE DISTRIBUTION FURTHER. Reported from a pair of decks on
|
|
189
|
+
// one router: a Mac heard an Arch machine's beacon every thirty seconds and
|
|
190
|
+
// every dial to it timed out. The Arch box was running Omarchy, which installs
|
|
191
|
+
// `ufw` and enables it, and `ufw` denies inbound by default — so the beacon
|
|
192
|
+
// left (outbound is allowed) and nothing could ever get back in. Both sides
|
|
193
|
+
// read `handshake timed out`, which names the symptom and nothing else.
|
|
194
|
+
//
|
|
195
|
+
// WHAT CANNOT BE READ HERE, and it is the difference from the Windows path
|
|
196
|
+
// above. `ufw status` is root-only — it reads /etc/ufw/user.rules, mode 0640
|
|
197
|
+
// root:root — so an ordinary process can learn that the firewall is ON and
|
|
198
|
+
// what its default inbound policy is, and CANNOT learn whether somebody has
|
|
199
|
+
// already allowed these two ports. firewalld is the same: `--state` answers
|
|
200
|
+
// anybody, `--list-ports` answers root.
|
|
201
|
+
//
|
|
202
|
+
// SO THE MEASUREMENT CARRIES THE VERDICT AND THE CONFIGURATION ONLY RAISES
|
|
203
|
+
// THE QUESTION. `inbound` is when a connection from another machine last
|
|
204
|
+
// arrived on the sync listener (see lan-engine's inboundAt). If one ever has,
|
|
205
|
+
// the path is open and nothing here has an opinion, whatever the rule files
|
|
206
|
+
// say. Only when the firewall is on, its inbound default drops, and nothing
|
|
207
|
+
// has ever got in does this speak — and it says what it measured, in those
|
|
208
|
+
// terms, rather than claiming to have read a rule it cannot read.
|
|
209
|
+
//
|
|
210
|
+
// THE COMMANDS ARE SAFE TO PASTE TWICE. `ufw allow` on a rule that exists
|
|
211
|
+
// prints "Skipping adding existing rule" and changes nothing; firewalld's
|
|
212
|
+
// --add-port is idempotent the same way. That is what makes an unverifiable
|
|
213
|
+
// question worth asking at all: the cost of a false alarm is one paste.
|
|
214
|
+
|
|
215
|
+
/** Both world-readable (0644), on every distribution that ships ufw: the
|
|
216
|
+
* switch, and the default this feature lives or dies on. */
|
|
217
|
+
export const UFW_CONF = "/etc/ufw/ufw.conf";
|
|
218
|
+
export const UFW_DEFAULTS = "/etc/default/ufw";
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* What ufw's two readable files say: whether it is on, and what it does with
|
|
222
|
+
* an inbound packet no rule matched.
|
|
223
|
+
*
|
|
224
|
+
* Both are shell fragments sourced by ufw's own scripts, so the values may or
|
|
225
|
+
* may not be quoted and the file may hold comments and blank lines. Anything
|
|
226
|
+
* unreadable — a machine with no ufw at all — comes in as null and reads as
|
|
227
|
+
* off, which is the answer that says nothing rather than the one that blames.
|
|
228
|
+
*/
|
|
229
|
+
export function readUfw(conf, defaults) {
|
|
230
|
+
const on = /^[ \t]*ENABLED[ \t]*=[ \t]*["']?yes["']?[ \t]*$/im.test(String(conf ?? ""));
|
|
231
|
+
const raw = /^[ \t]*DEFAULT_INPUT_POLICY[ \t]*=[ \t]*["']?([A-Za-z]+)["']?/im.exec(String(defaults ?? ""));
|
|
232
|
+
return { enabled: on, input: (raw?.[1] ?? "").toUpperCase() };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A systemd unit's state, from `systemctl is-active <unit>`. The command exits
|
|
236
|
+
* non-zero for anything that is not active, so its output is what is read and
|
|
237
|
+
* its status is not: `inactive`, `failed` and `unknown` are all "not running"
|
|
238
|
+
* and only one of them is an error. */
|
|
239
|
+
export const isActive = out => String(out ?? "").trim().split("\n")[0]?.trim() === "active";
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The two lines that open the path, as text.
|
|
243
|
+
*
|
|
244
|
+
* SCOPED TO PORTS, because Linux firewalls have no equivalent of the
|
|
245
|
+
* program-scoped rule the Windows path uses. That makes the sync port's
|
|
246
|
+
* stability load-bearing rather than merely nice: the listener asks for the
|
|
247
|
+
* port it had last time (see createSyncServer), so a rule written today is
|
|
248
|
+
* still the right rule after a restart. A deck that has not started listening
|
|
249
|
+
* yet has no port to name, and then only the discovery line is offered — half
|
|
250
|
+
* an answer, and the half that never changes.
|
|
251
|
+
*/
|
|
252
|
+
export function linuxFixSteps({ tool, syncPort, discoveryPort }) {
|
|
253
|
+
const sync = Number.isInteger(syncPort) && syncPort > 0 ? syncPort : null;
|
|
254
|
+
if (tool === "firewalld") {
|
|
255
|
+
const steps = [`sudo firewall-cmd --permanent --add-port=${discoveryPort}/udp`];
|
|
256
|
+
if (sync) steps.push(`sudo firewall-cmd --permanent --add-port=${sync}/tcp`);
|
|
257
|
+
steps.push("sudo firewall-cmd --reload");
|
|
258
|
+
return steps;
|
|
259
|
+
}
|
|
260
|
+
const steps = [`sudo ufw allow ${discoveryPort}/udp comment 'ccdeck discovery'`];
|
|
261
|
+
if (sync) steps.push(`sudo ufw allow ${sync}/tcp comment 'ccdeck sync'`);
|
|
262
|
+
return steps;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Whether other decks can reach this Linux machine, from what can be read
|
|
267
|
+
* without root plus the one thing that was measured.
|
|
268
|
+
*
|
|
269
|
+
* Null for a machine with no firewall running, which is most of them: nothing
|
|
270
|
+
* to say, so nothing said.
|
|
271
|
+
*/
|
|
272
|
+
export function linuxReach({ ufw = null, firewalld = false, inbound = null, syncPort = null, discoveryPort = 45_317 } = {}) {
|
|
273
|
+
// MEASURED BEATS READ. A connection from another machine has arrived, so
|
|
274
|
+
// whatever the rules are, they let this through.
|
|
275
|
+
if (inbound) return { blocked: false, why: "inbound seen", category: "", alias: "" };
|
|
276
|
+
const drops = ufw?.enabled && (ufw.input === "DROP" || ufw.input === "REJECT");
|
|
277
|
+
const tool = drops ? "ufw" : firewalld ? "firewalld" : null;
|
|
278
|
+
if (!tool) return null;
|
|
279
|
+
return {
|
|
280
|
+
blocked: true,
|
|
281
|
+
why: `${tool} inbound default`,
|
|
282
|
+
category: "",
|
|
283
|
+
alias: "",
|
|
284
|
+
shell: "sh",
|
|
285
|
+
tool,
|
|
286
|
+
// The reason in the reader's terms. Both halves are said because the
|
|
287
|
+
// asymmetry is the confusing part: their deck may already show this
|
|
288
|
+
// machine's name, which reads as a working connection and is not one.
|
|
289
|
+
text: tool === "ufw"
|
|
290
|
+
? "ufw is running here and drops what it was not told to allow, so other decks cannot reach this one. They can still hear it — that is why one of them may already show this machine."
|
|
291
|
+
: "firewalld is running here and drops what it was not told to allow, so other decks cannot reach this one. They can still hear it — that is why one of them may already show this machine.",
|
|
292
|
+
// Said out loud, because it is the difference between this verdict and the
|
|
293
|
+
// Windows one and a reader deserves to know which they are holding.
|
|
294
|
+
unsure: `${tool} does not show its rules to anything but root, so this cannot tell whether the two ports are already allowed. Running the lines again when they are changes nothing.`,
|
|
295
|
+
steps: linuxFixSteps({ tool, syncPort, discoveryPort }),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
186
299
|
/**
|
|
187
300
|
* Whether other decks can reach this one, and what to do when they cannot.
|
|
188
301
|
*
|
|
189
302
|
* `null` means "no opinion", and it is the answer for every machine this cannot
|
|
190
|
-
* speak about: anything that is
|
|
191
|
-
* back. Silence rather than a hedge — a panel line that says
|
|
192
|
-
* thing it did not measure is worse than no line.
|
|
303
|
+
* speak about: anything that is neither Windows nor Linux, and any probe that
|
|
304
|
+
* did not come back. Silence rather than a hedge — a panel line that says
|
|
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.
|
|
193
312
|
*
|
|
194
313
|
* `blocked: false` is a real finding and is worth returning: it lets the panel
|
|
195
314
|
* stop blaming the firewall for an empty list and say the other thing instead,
|
|
196
315
|
* which is that nobody else is running.
|
|
197
316
|
*/
|
|
198
|
-
export function reachability({ platform, probe, aliases = [], exePath = "" } = {}) {
|
|
317
|
+
export function reachability({ platform, probe, aliases = [], exePath = "", inbound = null, linux = null } = {}) {
|
|
318
|
+
// Two platforms, two entirely different reads — see the linux block above for
|
|
319
|
+
// why that one cannot be written in this one's shape. `inbound` is the one
|
|
320
|
+
// input both of them share: it is a measurement rather than a read, so it
|
|
321
|
+
// outranks whatever either platform's configuration says.
|
|
322
|
+
if (platform === "linux") return linux ? linuxReach({ ...linux, inbound }) : null;
|
|
199
323
|
if (platform !== "win32") return null;
|
|
200
324
|
if (!probe) return null;
|
|
201
325
|
// The broadcast route first, then the interfaces the deck holds an address
|
|
@@ -206,6 +330,10 @@ export function reachability({ platform, probe, aliases = [], exePath = "" } = {
|
|
|
206
330
|
const category = net?.category ?? "";
|
|
207
331
|
const name = profileFor(category);
|
|
208
332
|
if (!name) return null;
|
|
333
|
+
// MEASURED BEATS READ, here as on Linux: a connection from another machine
|
|
334
|
+
// has arrived on the sync listener, so the path is open whatever rule this
|
|
335
|
+
// was about to fail to find. See lan-engine's inboundAt.
|
|
336
|
+
if (inbound) return { blocked: false, why: "inbound seen", category, alias: net?.alias ?? "" };
|
|
209
337
|
const prof = probe.profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
|
|
210
338
|
// A firewall that is off blocks nothing, and saying otherwise would send
|
|
211
339
|
// somebody to add a rule that changes nothing.
|
|
@@ -218,6 +346,10 @@ export function reachability({ platform, probe, aliases = [], exePath = "" } = {
|
|
|
218
346
|
why: "no inbound rule",
|
|
219
347
|
category,
|
|
220
348
|
alias: net?.alias ?? "",
|
|
349
|
+
// Which shell the steps are written in, so the panel says where to paste
|
|
350
|
+
// them without asking what platform it is drawing for. Every other field
|
|
351
|
+
// here is already the verdict's to choose; this is one more.
|
|
352
|
+
shell: "powershell",
|
|
221
353
|
// The reason in the reader's terms, not the registry's. What they need to
|
|
222
354
|
// know is which half is broken, because the other half is what makes the
|
|
223
355
|
// workaround below obvious rather than magic.
|
|
@@ -429,6 +429,20 @@ export function createSyncServer({
|
|
|
429
429
|
/** One was used. The caller stores the pairing and retires the invite: a
|
|
430
430
|
* token that pairs twice is a token worth stealing twice. */
|
|
431
431
|
onInviteUsed,
|
|
432
|
+
/** A connection arrived, before anything about it is known — called with the
|
|
433
|
+
* address it came from and nothing else.
|
|
434
|
+
*
|
|
435
|
+
* THIS IS THE ONE MEASUREMENT OF INBOUND THERE IS. Everything else in this
|
|
436
|
+
* feature that asks "can other decks reach this one" reads a firewall's
|
|
437
|
+
* configuration and reasons about it, which is a guess wearing a verdict's
|
|
438
|
+
* clothes: `ufw` does not let an ordinary process read its rules at all. A
|
|
439
|
+
* socket that was accepted is proof the packets get in, and it costs one
|
|
440
|
+
* call at the one place every inbound connection already passes through.
|
|
441
|
+
*
|
|
442
|
+
* Deliberately BEFORE the handshake: a caller that fails to prove itself
|
|
443
|
+
* still proved the path. Whether the address is this machine's own is the
|
|
444
|
+
* caller's to judge — see lan-engine, which holds that list. */
|
|
445
|
+
onInbound,
|
|
432
446
|
/** How long a socket may say nothing before it is dropped — see IDLE_MS,
|
|
433
447
|
* which is what the deck runs on. A parameter only so the suite can drive
|
|
434
448
|
* the reclaim in a few hundred milliseconds rather than half a minute; a
|
|
@@ -475,6 +489,10 @@ export function createSyncServer({
|
|
|
475
489
|
if (mine >= MAX_SOCKETS_PER_HOST) { sock.destroy(); return; }
|
|
476
490
|
}
|
|
477
491
|
live.add(sock);
|
|
492
|
+
// Before the handshake and before the encoding: what this says is that a
|
|
493
|
+
// packet from somewhere else reached this listener, which is true of a
|
|
494
|
+
// connection that goes on to fail every check after it.
|
|
495
|
+
if (here) { try { onInbound?.(here); } catch { /* a reader, never a gate */ } }
|
|
478
496
|
sock.setEncoding("utf8");
|
|
479
497
|
sock.setNoDelay(true);
|
|
480
498
|
// ARMED HERE RATHER THAN AT `authed`, and that is the point of it: the
|