agent-dag 3.19.0 → 3.21.0
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 +176 -6
- package/bin/agent-dag.js +125 -4
- package/bin/deck.js +532 -4
- package/dist/web/assets/index-BYMVIG6f.css +1 -0
- package/dist/web/assets/index-G0ntHhBn.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/activity.mjs +52 -0
- package/src/server/args.mjs +27 -0
- package/src/server/auto-update.mjs +79 -0
- package/src/server/claude-accounts.mjs +205 -6
- package/src/server/cswap-admin.mjs +104 -11
- package/src/server/deck-home.mjs +243 -0
- package/src/server/deck-prefs.mjs +88 -8
- package/src/server/deck-probe.mjs +111 -0
- package/src/server/detach.mjs +244 -0
- package/src/server/global-install.mjs +67 -0
- package/src/server/index.mjs +275 -88
- package/src/server/installer.mjs +33 -8
- package/src/server/lan-about.mjs +119 -0
- package/src/server/lan-engine.mjs +142 -9
- package/src/server/lan-socket.mjs +94 -11
- package/src/server/lan-sync.mjs +5 -1
- package/src/server/login-service.mjs +473 -0
- package/src/server/presence.mjs +40 -0
- package/src/server/running-deck.mjs +234 -0
- package/src/server/self-update.mjs +5 -2
- package/src/server/stop-deck.mjs +171 -0
- package/src/server/supervisor.mjs +90 -0
- package/src/server/term.mjs +35 -2
- package/dist/web/assets/index-CE-8EKi6.css +0 -1
- package/dist/web/assets/index-DTbVC1d9.js +0 -266
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// What a paired deck says about itself: the version it runs and the machine it
|
|
2
|
+
// runs on.
|
|
3
|
+
//
|
|
4
|
+
// ONLY TO A DECK THAT IS PAIRED, AND SEALED. The beacon is shouted at the whole
|
|
5
|
+
// network and the hello is sent before anybody has proved anything, so neither
|
|
6
|
+
// carries this: which build of which operating system a machine runs is the
|
|
7
|
+
// first thing somebody hunting for a known-broken version would want, and
|
|
8
|
+
// nobody on the network is owed it. It rides the manifest exchange, which only
|
|
9
|
+
// happens between two decks that have each accepted the other — and it is
|
|
10
|
+
// sealed with that connection's key, because the frames around it are JSON in
|
|
11
|
+
// the clear on the wire.
|
|
12
|
+
//
|
|
13
|
+
// BOTH WAYS. The deck that dials sends its own card with the question, and the
|
|
14
|
+
// deck that answers sends its own with the answer. A deck that only calls in —
|
|
15
|
+
// paired, with no address this one can reach — is never asked anything, so the
|
|
16
|
+
// card it sends when it calls is the only way this deck ever learns what it is.
|
|
17
|
+
//
|
|
18
|
+
// OLDER DECKS SAY NOTHING, AND NOTHING BREAKS. The field is extra on a frame
|
|
19
|
+
// both versions already send: a deck from before this reads the accounts and
|
|
20
|
+
// never looks at the rest, and a card that is absent is drawn as absent.
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { release as osRelease } from "node:os";
|
|
23
|
+
import { open, seal } from "./lan-sync.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The operating system, in the words its owner would use.
|
|
27
|
+
*
|
|
28
|
+
* Node reports the KERNEL — `darwin 25.5.0`, `win32 10.0.26100` — and nobody
|
|
29
|
+
* calls their laptop Darwin 25. So the numbers are translated on the machine
|
|
30
|
+
* they describe, where the translation is certain, rather than on the one
|
|
31
|
+
* reading them. Every input is a parameter, so all three platforms are tested
|
|
32
|
+
* on any one of them.
|
|
33
|
+
*/
|
|
34
|
+
export function osLabel({ platform, release = "", prettyName = "" } = {}) {
|
|
35
|
+
const [major, minor, build] = String(release).split(".").map(n => Number.parseInt(n, 10));
|
|
36
|
+
const dot = Number.isInteger(minor) ? `.${minor}` : "";
|
|
37
|
+
if (platform === "darwin") {
|
|
38
|
+
if (!Number.isInteger(major)) return "macOS";
|
|
39
|
+
// Darwin 20 was macOS 11 and every major after it was one more — until 25,
|
|
40
|
+
// which Apple shipped as macOS 26 to match the year.
|
|
41
|
+
if (major >= 25) return `macOS ${major + 1}${dot}`;
|
|
42
|
+
if (major >= 20) return `macOS ${major - 9}${dot}`;
|
|
43
|
+
// The 10.x years, where the kernel's major minus four is the second number.
|
|
44
|
+
return major >= 5 ? `macOS 10.${major - 4}` : "macOS";
|
|
45
|
+
}
|
|
46
|
+
if (platform === "win32") {
|
|
47
|
+
// Windows 11 kept the version 10.0 and moved only the build, and 22000 is
|
|
48
|
+
// the first one. From here it is the only way to tell the two apart.
|
|
49
|
+
if (major === 10 && Number.isInteger(build)) return build >= 22_000 ? "Windows 11" : "Windows 10";
|
|
50
|
+
return "Windows";
|
|
51
|
+
}
|
|
52
|
+
if (platform === "linux") return prettyName || "Linux";
|
|
53
|
+
return typeof platform === "string" && platform ? platform : "unknown";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The distribution's own name for itself, from the file every systemd-era
|
|
57
|
+
* distribution ships, with the fallback path the same spec names. Only ever
|
|
58
|
+
* read on Linux: the paths are Linux's and mean nothing anywhere else. */
|
|
59
|
+
export function linuxPrettyName(read = readFileSync) {
|
|
60
|
+
for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
|
|
61
|
+
try {
|
|
62
|
+
const line = /^PRETTY_NAME=(.*)$/m.exec(String(read(file, "utf8")))?.[1];
|
|
63
|
+
const name = line?.trim().replace(/^(["'])(.*)\1$/, "$2").trim();
|
|
64
|
+
if (name) return name;
|
|
65
|
+
} catch { /* not this one */ }
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** This deck's own card. Built once, at start: nothing in it changes while the
|
|
71
|
+
* process lives, and an upgrade is a restart. */
|
|
72
|
+
export function aboutThisDeck({
|
|
73
|
+
version = null, platform = process.platform, release = osRelease(), arch = process.arch, read = readFileSync,
|
|
74
|
+
} = {}) {
|
|
75
|
+
return readAbout({
|
|
76
|
+
version,
|
|
77
|
+
os: osLabel({ platform, release, prettyName: platform === "linux" ? linuxPrettyName(read) : "" }),
|
|
78
|
+
arch,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One field of a card, or null. It came off the network, so it is a string of
|
|
83
|
+
* bounded length with no control characters — the rule cleanName applies to a
|
|
84
|
+
* deck's name, for the same reason: it reaches a terminal as well as a page.
|
|
85
|
+
* `\p{Cc}` is the Unicode class for exactly those, C0 and C1 both. */
|
|
86
|
+
function field(v, max, shape = null) {
|
|
87
|
+
if (typeof v !== "string") return null;
|
|
88
|
+
const flat = v.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
89
|
+
const s = [...flat].slice(0, max).join("").trim();
|
|
90
|
+
return s && (!shape || shape.test(s)) ? s : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A card, or null when there is nothing in it worth drawing. */
|
|
94
|
+
export function readAbout(raw) {
|
|
95
|
+
if (!raw || typeof raw !== "object") return null;
|
|
96
|
+
const version = field(raw.version, 32, /^[0-9A-Za-z][0-9A-Za-z.+-]*$/);
|
|
97
|
+
const os = field(raw.os, 48);
|
|
98
|
+
const arch = field(raw.arch, 16, /^[0-9A-Za-z_]+$/);
|
|
99
|
+
return version || os || arch ? { version, os, arch } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bound to one connection AND one direction, so a card cannot be replayed
|
|
103
|
+
* back at the deck that sent it as if it were the other deck's. */
|
|
104
|
+
const aboutAad = (fromFp, toFp) => `${fromFp}->${toFp}|about`;
|
|
105
|
+
|
|
106
|
+
/** This deck's card, sealed for the deck on the other end of `key`. */
|
|
107
|
+
export function sealAbout(key, about, fromFp, toFp) {
|
|
108
|
+
if (!key || !about) return null;
|
|
109
|
+
return seal(key, JSON.stringify(about), aboutAad(fromFp, toFp));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The other deck's card, or null — for a deck that sent none, and for one
|
|
113
|
+
* whose seal does not open, which mean the same thing to the panel. */
|
|
114
|
+
export function openAbout(key, sealed, fromFp, toFp) {
|
|
115
|
+
if (!key || !sealed || typeof sealed !== "object") return null;
|
|
116
|
+
const text = open(key, sealed, aboutAad(fromFp, toFp));
|
|
117
|
+
if (text == null) return null;
|
|
118
|
+
try { return readAbout(JSON.parse(text)); } catch { return null; }
|
|
119
|
+
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
31
31
|
import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
|
|
32
32
|
import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
|
|
33
|
+
import { openAbout, sealAbout } from "./lan-about.mjs";
|
|
33
34
|
import { randomBytes } from "node:crypto";
|
|
34
35
|
import { hostname, networkInterfaces } from "node:os";
|
|
35
36
|
|
|
@@ -95,6 +96,20 @@ export function localAddresses(faces = networkInterfaces()) {
|
|
|
95
96
|
return out;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The accounts a peer's manifest listed, as the panel may keep them.
|
|
101
|
+
*
|
|
102
|
+
* It arrived from another machine, so it is read rather than trusted: strings
|
|
103
|
+
* where strings belong, a boolean for the verdict, and no more rows than a
|
|
104
|
+
* manifest may carry. What is kept is only what the deck's dialog draws.
|
|
105
|
+
*/
|
|
106
|
+
export function offered(list) {
|
|
107
|
+
return (Array.isArray(list) ? list : [])
|
|
108
|
+
.filter(a => a && typeof a.key === "string" && typeof a.email === "string")
|
|
109
|
+
.slice(0, 50)
|
|
110
|
+
.map(a => ({ key: a.key.slice(0, 320), email: a.email.slice(0, 254), alive: a.alive === true }));
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
/**
|
|
99
114
|
* One deck's LAN sync, from settings to a healed account.
|
|
100
115
|
*
|
|
@@ -118,8 +133,14 @@ export function createEngine({
|
|
|
118
133
|
* with. Nothing secret leaves, and it is still a test shouting at an office.
|
|
119
134
|
*/
|
|
120
135
|
createSocket,
|
|
136
|
+
/** This deck's own card — its version and its machine — handed to every
|
|
137
|
+
* paired deck and to nobody else. See lan-about.mjs. */
|
|
138
|
+
about = null,
|
|
121
139
|
} = {}) {
|
|
122
|
-
let cfg = {
|
|
140
|
+
let cfg = {
|
|
141
|
+
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
142
|
+
autoAsk: true, autoAccept: true, aliases: {},
|
|
143
|
+
};
|
|
123
144
|
let identity = null;
|
|
124
145
|
let beacon = null;
|
|
125
146
|
let server = null;
|
|
@@ -163,6 +184,16 @@ export function createEngine({
|
|
|
163
184
|
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
164
185
|
* recent only, because "what happened" is a question about now. */
|
|
165
186
|
const lastRound = new Map();
|
|
187
|
+
/** What each paired deck said about itself — version, operating system,
|
|
188
|
+
* architecture — keyed by fingerprint, most recent only. Filled from both
|
|
189
|
+
* directions: the manifest a deck answers with, and the question a deck that
|
|
190
|
+
* calls in asks. See lan-about.mjs. */
|
|
191
|
+
const aboutBy = new Map();
|
|
192
|
+
/** The accounts each paired deck offered in its last manifest, keyed by
|
|
193
|
+
* fingerprint. Kept APART from lastRound on purpose: a round that fails
|
|
194
|
+
* replaces that line, and the list a deck offered a minute ago is still the
|
|
195
|
+
* best answer to "what does it share" while it is unreachable. */
|
|
196
|
+
const offersBy = new Map();
|
|
166
197
|
/** When the last round FINISHED, whatever it did or failed to do. The panel's
|
|
167
198
|
* `↻` fires one on demand and the loop fires one on its own; a reader who
|
|
168
199
|
* pressed it wants to know it happened, and a reader who did not wants to
|
|
@@ -209,6 +240,15 @@ export function createEngine({
|
|
|
209
240
|
}));
|
|
210
241
|
};
|
|
211
242
|
|
|
243
|
+
/** This deck's card for one connection, as a frame field — or nothing, for a
|
|
244
|
+
* deck built without one. Spread into the frame, so a deck from before this
|
|
245
|
+
* existed receives exactly the frame it always did plus one key it never
|
|
246
|
+
* reads. */
|
|
247
|
+
const cardFor = (key, toFp) => {
|
|
248
|
+
const sealed = sealAbout(key, about, identity.fp, toFp);
|
|
249
|
+
return sealed ? { about: sealed } : {};
|
|
250
|
+
};
|
|
251
|
+
|
|
212
252
|
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
213
253
|
* accepted. Nothing reaches this before both, which is the whole point of
|
|
214
254
|
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
@@ -219,8 +259,16 @@ export function createEngine({
|
|
|
219
259
|
if (ctx?.peerFp) spokeAt.set(ctx.peerFp, now());
|
|
220
260
|
try {
|
|
221
261
|
if (msg.t === "manifest") {
|
|
262
|
+
// THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
|
|
263
|
+
// that calls in ever says what it is: nothing here dials it, so nothing
|
|
264
|
+
// here ever asks. A seal that does not open is a deck that said nothing.
|
|
265
|
+
const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
|
|
266
|
+
if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
|
|
222
267
|
const accounts = await localAccounts();
|
|
223
|
-
return ctx.send({
|
|
268
|
+
return ctx.send({
|
|
269
|
+
t: "manifest", accounts: manifestFor(accounts, cfg.shared),
|
|
270
|
+
...cardFor(ctx.key, ctx.peerFp),
|
|
271
|
+
});
|
|
224
272
|
}
|
|
225
273
|
if (msg.t === "want") {
|
|
226
274
|
// A SECOND PROOF, for the one operation that moves a credential. The
|
|
@@ -310,13 +358,18 @@ export function createEngine({
|
|
|
310
358
|
|
|
311
359
|
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
312
360
|
const { list, added } = addTrusted(cfg.trusted, {
|
|
313
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
|
|
361
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
|
|
314
362
|
});
|
|
315
363
|
if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
|
|
316
364
|
}
|
|
317
365
|
|
|
318
|
-
|
|
366
|
+
// Our card goes with the question and theirs comes back with the answer
|
|
367
|
+
// — see lan-about.mjs for why it is here and nowhere earlier.
|
|
368
|
+
const theirs = await ask({ t: "manifest", ...cardFor(conn.key, conn.peerFp) });
|
|
319
369
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
370
|
+
const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
|
|
371
|
+
if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
|
|
372
|
+
offersBy.set(conn.peerFp, { at: now(), accounts: offered(theirs.accounts) });
|
|
320
373
|
const mine = await localAccounts();
|
|
321
374
|
// Only accounts I have also ticked. Sharing is mutual by construction:
|
|
322
375
|
// a peer cannot push an account at me that I never agreed to hold.
|
|
@@ -341,8 +394,16 @@ export function createEngine({
|
|
|
341
394
|
if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
|
|
342
395
|
const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
|
|
343
396
|
if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
|
|
344
|
-
|
|
345
|
-
|
|
397
|
+
// A verdict rather than a boolean, because "refused" and "kept the
|
|
398
|
+
// slot it already has" are different things to tell somebody and the
|
|
399
|
+
// second one used to be reported as success. A bare `true` is still
|
|
400
|
+
// accepted: the suite drives this with one.
|
|
401
|
+
// The step goes down with the blob: the wiring has to know WHICH account
|
|
402
|
+
// it is placing before it may treat a decline as an empty slot rather
|
|
403
|
+
// than as a healthy one.
|
|
404
|
+
const got = await importAccount(blob, step);
|
|
405
|
+
const ok = got === true || got?.ok === true;
|
|
406
|
+
done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
|
|
346
407
|
}
|
|
347
408
|
lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
|
|
348
409
|
if (done.length) onChange?.();
|
|
@@ -396,8 +457,23 @@ export function createEngine({
|
|
|
396
457
|
|
|
397
458
|
return {
|
|
398
459
|
async apply(next) {
|
|
460
|
+
/** The engine itself, for the callbacks handed to the socket below: they
|
|
461
|
+
* outlive this call and `this` is not theirs to keep. */
|
|
462
|
+
const self = this;
|
|
399
463
|
const was = cfg;
|
|
400
464
|
cfg = { ...cfg, ...next };
|
|
465
|
+
// TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
|
|
466
|
+
// this on with two rows sitting in the panel means those two as much as
|
|
467
|
+
// the next one, and leaving them queued behind a setting called
|
|
468
|
+
// "automatic" is the switch not doing what it says.
|
|
469
|
+
if (!was.autoAccept && cfg.autoAccept) {
|
|
470
|
+
for (const fp of [...pending.keys()]) this.accept(fp);
|
|
471
|
+
}
|
|
472
|
+
// The same for the other direction: switching `ask` on with four machines
|
|
473
|
+
// already listed asks those four.
|
|
474
|
+
if (!was.autoAsk && cfg.autoAsk) {
|
|
475
|
+
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub) this.accept(fp);
|
|
476
|
+
}
|
|
401
477
|
const restart = !was.enabled !== !cfg.enabled
|
|
402
478
|
|| was.secret !== cfg.secret
|
|
403
479
|
|| was.name !== cfg.name;
|
|
@@ -423,7 +499,7 @@ export function createEngine({
|
|
|
423
499
|
// Somebody used the token. They are pinned, and the token is retired —
|
|
424
500
|
// one that pairs twice is one worth stealing twice.
|
|
425
501
|
onInviteUsed: entry => {
|
|
426
|
-
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
|
|
502
|
+
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
|
|
427
503
|
cfg = { ...cfg, trusted: list };
|
|
428
504
|
invite = null;
|
|
429
505
|
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
@@ -451,6 +527,16 @@ export function createEngine({
|
|
|
451
527
|
onPending: entry => {
|
|
452
528
|
const had = pending.get(entry.fp);
|
|
453
529
|
pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
|
|
530
|
+
// SAY YES FOR SOMEBODY WHO SAID TO. It is the accept button and
|
|
531
|
+
// nothing else: the same pin, from the same key this handshake just
|
|
532
|
+
// proved, so the deck is trusted on its next attempt a few seconds
|
|
533
|
+
// later exactly as it would be if a person had pressed it. Nothing
|
|
534
|
+
// about the wire changes — this connection is still refused, because
|
|
535
|
+
// trust is read fresh per connection.
|
|
536
|
+
//
|
|
537
|
+
// A deck already told no does NOT come back this way: lan-socket
|
|
538
|
+
// refuses it before this is ever called.
|
|
539
|
+
if (cfg.autoAccept) { self.accept(entry.fp); return; }
|
|
454
540
|
if (!had) onChange?.();
|
|
455
541
|
},
|
|
456
542
|
});
|
|
@@ -477,6 +563,16 @@ export function createEngine({
|
|
|
477
563
|
// one of them drew a row offering to pair with the same machine.
|
|
478
564
|
if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
|
|
479
565
|
strangers.set(entry.fp, entry);
|
|
566
|
+
// ASK IT, which is what the `ask` verb on its row does and nothing
|
|
567
|
+
// more: the address goes on the dial list and the next round sends a
|
|
568
|
+
// request that somebody over there still has to answer. A beacon
|
|
569
|
+
// carries a fingerprint and no key, so nothing is pinned here — see
|
|
570
|
+
// accept, which is deliberate about the difference.
|
|
571
|
+
//
|
|
572
|
+
// Only a deck that is NEW is asked, or a beacon every thirty seconds
|
|
573
|
+
// would be thirty seconds of asking; and never one this deck's owner
|
|
574
|
+
// already turned away.
|
|
575
|
+
if (cfg.autoAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp); return; }
|
|
480
576
|
// Only a deck that is new to us is news. A beacon every thirty
|
|
481
577
|
// seconds from one already on the list is not a reason to redraw.
|
|
482
578
|
if (!had) onChange?.();
|
|
@@ -566,7 +662,7 @@ export function createEngine({
|
|
|
566
662
|
name: cfg.name, myPort: server.port(), code: inv.code,
|
|
567
663
|
});
|
|
568
664
|
const { list } = addTrusted(cfg.trusted, {
|
|
569
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
|
|
665
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
570
666
|
});
|
|
571
667
|
cfg = { ...cfg, trusted: list };
|
|
572
668
|
this.addPeer(at.addr, at.port);
|
|
@@ -621,7 +717,7 @@ export function createEngine({
|
|
|
621
717
|
onChange?.();
|
|
622
718
|
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
623
719
|
}
|
|
624
|
-
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
|
|
720
|
+
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
|
|
625
721
|
cfg = { ...cfg, trusted: list };
|
|
626
722
|
pending.delete(fp);
|
|
627
723
|
strangers.delete(fp);
|
|
@@ -678,6 +774,24 @@ export function createEngine({
|
|
|
678
774
|
return true;
|
|
679
775
|
},
|
|
680
776
|
round,
|
|
777
|
+
/**
|
|
778
|
+
* One deck, now — the `check now` in that deck's own dialog.
|
|
779
|
+
*
|
|
780
|
+
* Found the way the list found it: heard on the network under its own
|
|
781
|
+
* fingerprint, or dialled at an address whose answer was that fingerprint.
|
|
782
|
+
* Null when it is neither, which is a deck that only calls in — nothing
|
|
783
|
+
* here holds an address for it, so there is nobody to dial.
|
|
784
|
+
*
|
|
785
|
+
* `roundAt` is left alone: it says when EVERY paired deck was last asked,
|
|
786
|
+
* and asking one of them does not make that true.
|
|
787
|
+
*/
|
|
788
|
+
async roundOne(fp) {
|
|
789
|
+
if (!beacon || typeof fp !== "string" || !fp) return null;
|
|
790
|
+
const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
|
|
791
|
+
const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
|
|
792
|
+
const peer = heard ?? typed;
|
|
793
|
+
return peer ? roundWith(peer) : null;
|
|
794
|
+
},
|
|
681
795
|
/** Dial this address on every round from now on. Returns false for an
|
|
682
796
|
* address that is not one, rather than storing a row that can never
|
|
683
797
|
* connect and reports an error every minute forever. */
|
|
@@ -712,6 +826,15 @@ export function createEngine({
|
|
|
712
826
|
// which on a deck that has just started is the honest answer.
|
|
713
827
|
checkedAt: roundAt,
|
|
714
828
|
name: cfg.name,
|
|
829
|
+
// This deck's own card, so the panel can read a peer's version against
|
|
830
|
+
// it; and the names somebody here gave other decks, which the panel
|
|
831
|
+
// and the request dialog draw in place of the ones those decks chose.
|
|
832
|
+
about: about ?? null,
|
|
833
|
+
aliases: { ...(cfg.aliases ?? {}) },
|
|
834
|
+
// Whether this deck asks on its own, and whether a request that
|
|
835
|
+
// arrives is answered here or answered for you.
|
|
836
|
+
autoAsk: !!cfg.autoAsk,
|
|
837
|
+
autoAccept: !!cfg.autoAccept,
|
|
715
838
|
fp: identity?.fp ?? null,
|
|
716
839
|
// The address and port a person on another subnet types into the other
|
|
717
840
|
// deck's field. Null when this machine has no ordinary one, which the
|
|
@@ -770,6 +893,15 @@ export function createEngine({
|
|
|
770
893
|
had.met = had.met || row.met;
|
|
771
894
|
if (row.name && !had.name) had.name = row.name;
|
|
772
895
|
};
|
|
896
|
+
// WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
|
|
897
|
+
// the logins it offered last, and when somebody here said yes. All
|
|
898
|
+
// three are keyed by the fingerprint that proved itself, so both
|
|
899
|
+
// halves of a merged row read the same answer.
|
|
900
|
+
const card = id => ({
|
|
901
|
+
about: aboutBy.get(id) ?? null,
|
|
902
|
+
offers: offersBy.get(id) ?? null,
|
|
903
|
+
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
904
|
+
});
|
|
773
905
|
for (const p of [...beacon.peers.values(), ...manual.values()]) {
|
|
774
906
|
if (!stillListed(p, now())) continue;
|
|
775
907
|
const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
|
|
@@ -784,6 +916,7 @@ export function createEngine({
|
|
|
784
916
|
met: !!met,
|
|
785
917
|
paired: !!trustedPeer(cfg.trusted, id),
|
|
786
918
|
last: lastRound.get(p.fp) ?? null,
|
|
919
|
+
...card(id),
|
|
787
920
|
});
|
|
788
921
|
}
|
|
789
922
|
// A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
|
|
@@ -22,6 +22,57 @@
|
|
|
22
22
|
// all — and it is why self-recognition is by fingerprint rather than by
|
|
23
23
|
// address.
|
|
24
24
|
import dgram from "node:dgram";
|
|
25
|
+
import { networkInterfaces } from "node:os";
|
|
26
|
+
|
|
27
|
+
/** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
|
|
28
|
+
function quad(text) {
|
|
29
|
+
const parts = String(text ?? "").split(".");
|
|
30
|
+
if (parts.length !== 4) return null;
|
|
31
|
+
const out = parts.map(p => Number(p));
|
|
32
|
+
return out.every(n => Number.isInteger(n) && n >= 0 && n <= 255) ? out : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The address that reaches every host on one interface's own subnet.
|
|
37
|
+
*
|
|
38
|
+
* `address | ~netmask`, which is the definition. Null for a /32, because a
|
|
39
|
+
* point-to-point link — a VPN tunnel, `utun` on macOS — has a directed
|
|
40
|
+
* broadcast equal to its own address, and sending a beacon to ourselves down a
|
|
41
|
+
* tunnel is a packet nobody wanted.
|
|
42
|
+
*/
|
|
43
|
+
export function directedBroadcast(address, netmask) {
|
|
44
|
+
const a = quad(address);
|
|
45
|
+
const m = quad(netmask);
|
|
46
|
+
if (!a || !m) return null;
|
|
47
|
+
if (m.every(o => o === 255)) return null;
|
|
48
|
+
return a.map((o, i) => o | (~m[i] & 255)).join(".");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a beacon has to go to be heard on this machine's networks.
|
|
53
|
+
*
|
|
54
|
+
* The limited broadcast first, because it is the one that works where a router
|
|
55
|
+
* or an access point filters the directed form, and because it is what every
|
|
56
|
+
* deck before this version sent — a machine that was fine stays fine. Then one
|
|
57
|
+
* per interface, which is what a multi-homed host actually needs: see announce.
|
|
58
|
+
*
|
|
59
|
+
* Loopback and IPv6 are skipped. A deck on `lo0` can only hear itself, and this
|
|
60
|
+
* protocol is IPv4 broadcast by construction — there is no such thing as an
|
|
61
|
+
* IPv6 broadcast address.
|
|
62
|
+
*/
|
|
63
|
+
export function broadcastTargets(ifaces) {
|
|
64
|
+
const out = ["255.255.255.255"];
|
|
65
|
+
for (const list of Object.values(ifaces ?? {})) {
|
|
66
|
+
for (const ni of list ?? []) {
|
|
67
|
+
if (!ni || ni.internal) continue;
|
|
68
|
+
// Node 18 reports `family` as the string "IPv4"; older shapes used 4.
|
|
69
|
+
if (ni.family !== "IPv4" && ni.family !== 4) continue;
|
|
70
|
+
const to = directedBroadcast(ni.address, ni.netmask);
|
|
71
|
+
if (to && !out.includes(to)) out.push(to);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
25
76
|
import net from "node:net";
|
|
26
77
|
import { randomBytes } from "node:crypto";
|
|
27
78
|
import {
|
|
@@ -84,6 +135,11 @@ export function createBeacon({
|
|
|
84
135
|
// without saying so. The REAL socket is exercised by hand, two decks on one
|
|
85
136
|
// machine, which works because a broadcast comes back to its own host.
|
|
86
137
|
createSocket = opts => dgram.createSocket(opts),
|
|
138
|
+
// Injected for the same reason, and read PER ANNOUNCE rather than once: a
|
|
139
|
+
// laptop that joins a network, or brings a VPN up, grows an interface without
|
|
140
|
+
// restarting the deck, and a list captured at start would announce to the
|
|
141
|
+
// addresses it had at breakfast.
|
|
142
|
+
ifaces = () => networkInterfaces(),
|
|
87
143
|
} = {}) {
|
|
88
144
|
// Randomised per process. Two beacons from one fingerprint with different
|
|
89
145
|
// instance ids mean the deck restarted between them, which is the signal to
|
|
@@ -104,20 +160,47 @@ export function createBeacon({
|
|
|
104
160
|
let repliedAt = 0;
|
|
105
161
|
const answered = new Set();
|
|
106
162
|
|
|
107
|
-
|
|
163
|
+
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
|
|
108
164
|
|
|
109
165
|
const announce = () => {
|
|
110
166
|
if (!sock) return;
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
// EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
|
|
168
|
+
//
|
|
169
|
+
// This used to send only to 255.255.255.255, on the argument that the
|
|
170
|
+
// limited broadcast "needs nothing" while a subnet-directed one needs the
|
|
171
|
+
// netmask of whichever interface the packet leaves by. The argument is
|
|
172
|
+
// sound and the machine disagreed with it. Measured on a Mac with Wi-Fi and
|
|
173
|
+
// a VPN tunnel up, from a bare node process with no deck involved:
|
|
174
|
+
//
|
|
175
|
+
// send to 255.255.255.255 -> EHOSTUNREACH
|
|
176
|
+
// send to 192.168.1.255 -> sent ok
|
|
177
|
+
//
|
|
178
|
+
// On a multi-homed host the limited broadcast has no single interface to
|
|
179
|
+
// leave by, and macOS refuses it rather than choosing. The deck went on
|
|
180
|
+
// announcing into nothing for as long as that machine was up: it could still
|
|
181
|
+
// HEAR colleagues, because receiving is per-port and not per-address, so the
|
|
182
|
+
// symptom was one-sided and looked like everybody else's problem.
|
|
183
|
+
//
|
|
184
|
+
// So the limited form stays — it is the one that works where a directed
|
|
185
|
+
// broadcast is filtered, and it is what Syncthing sends — and every
|
|
186
|
+
// interface's own directed broadcast goes out beside it. A duplicate packet
|
|
187
|
+
// costs one datagram; a missing one costs the whole feature.
|
|
188
|
+
const targets = broadcastTargets(ifaces());
|
|
189
|
+
let left = targets.length;
|
|
190
|
+
const failed = [];
|
|
191
|
+
for (const to of targets) {
|
|
192
|
+
sock.send(payload(), DISCOVERY_PORT, to, err => {
|
|
193
|
+
if (err) failed.push(`${to} (${err.code ?? err.message})`);
|
|
194
|
+
// REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
|
|
195
|
+
// the ordinary state of a machine with a VPN up, and a panel that said
|
|
196
|
+
// so every thirty seconds would be crying wolf about a working deck.
|
|
197
|
+
// No address working at all is a deck nobody can discover, which is
|
|
198
|
+
// exactly what the panel is for.
|
|
199
|
+
if (--left === 0 && failed.length === targets.length) {
|
|
200
|
+
onError?.("announce", new Error(`no broadcast address worked — ${failed.join(", ")}`));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
121
204
|
};
|
|
122
205
|
|
|
123
206
|
const start = () => new Promise(resolve => {
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -592,7 +592,11 @@ export function addTrusted(trusted, entry) {
|
|
|
592
592
|
added: false,
|
|
593
593
|
};
|
|
594
594
|
}
|
|
595
|
-
|
|
595
|
+
// WHEN, for the panel's "paired since". Only a new pin gets one: a deck that
|
|
596
|
+
// was already trusted keeps whatever date it had, and one pinned before this
|
|
597
|
+
// field existed keeps having none rather than being given today's.
|
|
598
|
+
const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
|
|
599
|
+
return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
|
|
596
600
|
}
|
|
597
601
|
|
|
598
602
|
/** Take one back out. Unpairing stops what has not happened yet and takes back
|