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.
@@ -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
@@ -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 not Windows, and any probe that did not come
191
- * back. Silence rather than a hedge — a panel line that says "possibly" about a
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