agent-dag 3.15.0 → 3.16.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/dist/web/assets/index-C7_uf54q.js +254 -0
- package/dist/web/assets/{index-CqAgutJT.css → index-DPOZaaRr.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/server/deck-prefs.mjs +33 -14
- package/src/server/index.mjs +107 -24
- package/src/server/lan-engine.mjs +374 -66
- package/src/server/lan-socket.mjs +245 -63
- package/src/server/lan-sync.mjs +321 -86
- package/dist/web/assets/index-CbYzt04Y.js +0 -248
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
// anything.
|
|
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
|
-
import {
|
|
33
|
-
import {
|
|
32
|
+
import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
34
34
|
import { hostname, networkInterfaces } from "node:os";
|
|
35
35
|
|
|
36
36
|
/** How often a deck asks its peers what they have. A minute is far more often
|
|
@@ -44,32 +44,6 @@ export const SYNC_MS = 60_000;
|
|
|
44
44
|
export const ROUND_MS = 10_000;
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
* The name everybody knows this deck by.
|
|
49
|
-
*
|
|
50
|
-
* PERSISTED, and the first version was not. It regenerated on every start, on
|
|
51
|
-
* the argument that a stored key is one more secret to protect — and that
|
|
52
|
-
* argument was about the wrong thing, because this is not a key. Nothing is
|
|
53
|
-
* signed with it and nothing is decrypted with it; the group passphrase is what
|
|
54
|
-
* authenticates, and this is only an identifier, broadcast in the clear in
|
|
55
|
-
* every beacon. There is nothing here to steal.
|
|
56
|
-
*
|
|
57
|
-
* What regenerating it cost was measured rather than guessed: on a machine
|
|
58
|
-
* where decks had been restarted a few times, every peer's list held a row per
|
|
59
|
-
* restart — dozens of them, each reporting ECONNREFUSED every minute against a
|
|
60
|
-
* port nothing has listened on for an hour. Remembered forever and never
|
|
61
|
-
* recognised again is the worst of both, and a stable id is the whole fix.
|
|
62
|
-
*/
|
|
63
|
-
export function newIdentity(stored) {
|
|
64
|
-
if (typeof stored === "string" && /^[0-9a-f]{12}$/.test(stored)) {
|
|
65
|
-
return { fp: stored.replace(/(.{3})(?=.)/g, "$1-"), id: stored };
|
|
66
|
-
}
|
|
67
|
-
const { publicKey } = generateKeyPairSync("x25519");
|
|
68
|
-
const raw = publicKey.export({ type: "spki", format: "der" });
|
|
69
|
-
const fp = fingerprint(raw);
|
|
70
|
-
return { fp, id: fp.replace(/-/g, "") };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
47
|
/** What this machine calls itself when the user has not said. The hostname,
|
|
74
48
|
* because that is the word they already use for this machine everywhere else. */
|
|
75
49
|
export function defaultName() {
|
|
@@ -115,14 +89,41 @@ export function localAddresses(faces = networkInterfaces()) {
|
|
|
115
89
|
*/
|
|
116
90
|
export function createEngine({
|
|
117
91
|
readAccounts, exportAccount, importAccount,
|
|
118
|
-
onChange, onError, onIdentity, now = Date.now,
|
|
92
|
+
onChange, onError, onIdentity, onPort, onTrust, onDial, now = Date.now,
|
|
93
|
+
/**
|
|
94
|
+
* The UDP socket the beacon shouts through, injectable for the same reason
|
|
95
|
+
* lan-socket exposes it — and for one more that only showed up in use.
|
|
96
|
+
*
|
|
97
|
+
* The suite runs whole engines over real sockets, which is right: a handshake
|
|
98
|
+
* between two of them is the thing being tested and a mock would only check
|
|
99
|
+
* that the mock agrees with the code it was written from. But `createBeacon`
|
|
100
|
+
* defaulted to a real dgram socket, so `npm test` BROADCAST on whatever
|
|
101
|
+
* network the machine was on — and the fake decks it announces turned up in
|
|
102
|
+
* a real panel, on a real screen, in a list of decks somebody could pair
|
|
103
|
+
* with. Nothing secret leaves, and it is still a test shouting at an office.
|
|
104
|
+
*/
|
|
105
|
+
createSocket,
|
|
119
106
|
} = {}) {
|
|
120
|
-
let cfg = { enabled: false, name: defaultName(),
|
|
107
|
+
let cfg = { enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0 };
|
|
121
108
|
let identity = null;
|
|
122
109
|
let beacon = null;
|
|
123
110
|
let server = null;
|
|
124
111
|
let timer = null;
|
|
125
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Decks that finished a handshake and that nobody here has accepted yet, and
|
|
114
|
+
* decks merely heard shouting on the network. Two lists because they are two
|
|
115
|
+
* different claims: a pending deck proved it holds the key it announced, a
|
|
116
|
+
* heard one only said so. Both are rows with an accept on them; only the
|
|
117
|
+
* first is evidence.
|
|
118
|
+
*
|
|
119
|
+
* In memory rather than on disk. A request that is a day old is not a request
|
|
120
|
+
* any more, and a list of them that survives restarts is a list nobody reads.
|
|
121
|
+
*/
|
|
122
|
+
const pending = new Map();
|
|
123
|
+
const strangers = new Map();
|
|
124
|
+
/** The invite this deck is offering, or null. One at a time: a deck showing
|
|
125
|
+
* two tokens is a deck whose owner cannot say which one they sent. */
|
|
126
|
+
let invite = null;
|
|
126
127
|
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
127
128
|
* recent only, because "what happened" is a question about now. */
|
|
128
129
|
const lastRound = new Map();
|
|
@@ -139,8 +140,10 @@ export function createEngine({
|
|
|
139
140
|
}));
|
|
140
141
|
};
|
|
141
142
|
|
|
142
|
-
/** Frames from a
|
|
143
|
-
* before
|
|
143
|
+
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
144
|
+
* accepted. Nothing reaches this before both, which is the whole point of
|
|
145
|
+
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
146
|
+
* connection's — see sessionKey. */
|
|
144
147
|
const serve = async (msg, ctx) => {
|
|
145
148
|
try {
|
|
146
149
|
if (msg.t === "manifest") {
|
|
@@ -152,7 +155,7 @@ export function createEngine({
|
|
|
152
155
|
// session says who connected; this says they are asking for this
|
|
153
156
|
// account, now. A long-lived connection authenticated an hour ago is
|
|
154
157
|
// not a statement about now.
|
|
155
|
-
const want = transferChallenge(key, {
|
|
158
|
+
const want = transferChallenge(ctx.key, {
|
|
156
159
|
nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
|
|
157
160
|
});
|
|
158
161
|
if (typeof msg.proof !== "string" || msg.proof !== want) {
|
|
@@ -168,7 +171,7 @@ export function createEngine({
|
|
|
168
171
|
const blob = await exportAccount(mine.num);
|
|
169
172
|
if (!blob) return ctx.send({ t: "no", why: "export failed" });
|
|
170
173
|
const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
|
|
171
|
-
return ctx.send({ t: "have", key: msg.key, sealed: seal(key, blob, aad) });
|
|
174
|
+
return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
|
|
172
175
|
}
|
|
173
176
|
} catch (err) {
|
|
174
177
|
onError?.("serve", err);
|
|
@@ -180,7 +183,16 @@ export function createEngine({
|
|
|
180
183
|
const roundWith = async peer => {
|
|
181
184
|
let conn = null;
|
|
182
185
|
try {
|
|
183
|
-
conn = await connectToPeer({
|
|
186
|
+
conn = await connectToPeer({
|
|
187
|
+
host: peer.addr, port: peer.port, timeoutMs: ROUND_MS,
|
|
188
|
+
fp: identity.fp, pub: identity.pub, secret: identity.secret, name: cfg.name,
|
|
189
|
+
// Where this deck listens, so the far side can reach back after it
|
|
190
|
+
// accepts rather than only being reachable.
|
|
191
|
+
myPort: server?.port() ?? null,
|
|
192
|
+
// The key pinned when this deck was accepted, so a second machine
|
|
193
|
+
// answering at that address is refused rather than talked to.
|
|
194
|
+
expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
|
|
195
|
+
});
|
|
184
196
|
const ask = frame => new Promise((resolve, reject) => {
|
|
185
197
|
const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
|
|
186
198
|
bell.unref?.();
|
|
@@ -197,6 +209,30 @@ export function createEngine({
|
|
|
197
209
|
sendFrame(conn.sock, frame);
|
|
198
210
|
});
|
|
199
211
|
|
|
212
|
+
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY TYPED. Reaching a
|
|
213
|
+
// deck we have no pin for means the person at this keyboard put its
|
|
214
|
+
// address in the field, which is the same decision the accept button is
|
|
215
|
+
// on the other side. Pinning it here is what makes the two lists agree —
|
|
216
|
+
// without it this deck would dial a peer every minute and still show it
|
|
217
|
+
// as nobody, and its own listener would refuse the same deck calling
|
|
218
|
+
// back.
|
|
219
|
+
//
|
|
220
|
+
// A deck we DO have a pin for was checked before this line: connectToPeer
|
|
221
|
+
// was given expectPub and refuses a different key at that address.
|
|
222
|
+
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
223
|
+
// and nothing else until somebody answers it — and once one has, the deck
|
|
224
|
+
// on the other end has told us what it calls itself. The row says that
|
|
225
|
+
// from then on, because "Constantin-PC" is what the person who typed the
|
|
226
|
+
// address was trying to reach.
|
|
227
|
+
learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
|
|
228
|
+
|
|
229
|
+
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
230
|
+
const { list, added } = addTrusted(cfg.trusted, {
|
|
231
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
|
|
232
|
+
});
|
|
233
|
+
if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
|
|
234
|
+
}
|
|
235
|
+
|
|
200
236
|
const theirs = await ask({ t: "manifest" });
|
|
201
237
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
202
238
|
const mine = await localAccounts();
|
|
@@ -205,10 +241,10 @@ export function createEngine({
|
|
|
205
241
|
// A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
|
|
206
242
|
// Healing replaces a slot I already have, so it is only reasonable for an
|
|
207
243
|
// account I said I share. Adding is the case the owner asked for by name:
|
|
208
|
-
// an account that appears
|
|
209
|
-
// the whole of "I do not want to paste blobs any more".
|
|
210
|
-
//
|
|
211
|
-
//
|
|
244
|
+
// an account that appears among the decks I paired with appears on all of
|
|
245
|
+
// them, which is the whole of "I do not want to paste blobs any more".
|
|
246
|
+
// What can reach this is what a deck somebody here pressed accept on
|
|
247
|
+
// chose to offer.
|
|
212
248
|
const wanted = plan(mine, theirs.accounts)
|
|
213
249
|
.filter(step => step.action === "add" || cfg.shared.includes(step.key));
|
|
214
250
|
const done = [];
|
|
@@ -216,12 +252,12 @@ export function createEngine({
|
|
|
216
252
|
const nonce = randomBytes(12).toString("hex");
|
|
217
253
|
const reply = await ask({
|
|
218
254
|
t: "want", key: step.key, nonce,
|
|
219
|
-
proof: transferChallenge(key, {
|
|
255
|
+
proof: transferChallenge(conn.key, {
|
|
220
256
|
nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
|
|
221
257
|
}),
|
|
222
258
|
});
|
|
223
259
|
if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
|
|
224
|
-
const blob = open(key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
|
|
260
|
+
const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
|
|
225
261
|
if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
|
|
226
262
|
const ok = await importAccount(blob);
|
|
227
263
|
done.push({ ...step, ok: !!ok, why: ok ? null : "import failed" });
|
|
@@ -241,13 +277,19 @@ export function createEngine({
|
|
|
241
277
|
*
|
|
242
278
|
* Broadcast dies at the first router and is dropped by a switch that
|
|
243
279
|
* filters it, so a deck across a VPN or on another subnet is unreachable by
|
|
244
|
-
* discovery and perfectly reachable by address.
|
|
245
|
-
*
|
|
280
|
+
* discovery and perfectly reachable by address. Typing one is a decision to
|
|
281
|
+
* trust whatever answers there the first time, and to pin it: an address is
|
|
282
|
+
* a way to reach a deck, and the accept on the other machine is what lets
|
|
283
|
+
* anything move.
|
|
246
284
|
*
|
|
247
285
|
* Keyed by `host:port` rather than by fingerprint, because a fingerprint is
|
|
248
286
|
* what a deck says about itself after the handshake and this list has to
|
|
249
287
|
* exist before there has been one. */
|
|
250
288
|
const manual = new Map();
|
|
289
|
+
/** What answered at a typed address, once something has. Keyed the same way
|
|
290
|
+
* `manual` is, because until a connection succeeds an address is all there
|
|
291
|
+
* is to key on. */
|
|
292
|
+
const learned = new Map();
|
|
251
293
|
|
|
252
294
|
const round = async () => {
|
|
253
295
|
if (!beacon) return [];
|
|
@@ -274,36 +316,237 @@ export function createEngine({
|
|
|
274
316
|
const was = cfg;
|
|
275
317
|
cfg = { ...cfg, ...next };
|
|
276
318
|
const restart = !was.enabled !== !cfg.enabled
|
|
277
|
-
|| was.
|
|
278
|
-
|| was.name !== cfg.name
|
|
279
|
-
|| was.deckId !== cfg.deckId;
|
|
319
|
+
|| was.secret !== cfg.secret
|
|
320
|
+
|| was.name !== cfg.name;
|
|
280
321
|
if (!restart) return;
|
|
281
322
|
this.stop();
|
|
282
|
-
if (!cfg.enabled
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
323
|
+
if (!cfg.enabled) return;
|
|
324
|
+
identity = identityFrom(cfg.secret);
|
|
325
|
+
// Hand the caller a key to keep when there was none, so the next start is
|
|
326
|
+
// the same deck rather than a stranger to everybody who paired with it.
|
|
327
|
+
if (identity.secret !== cfg.secret) {
|
|
328
|
+
cfg = { ...cfg, secret: identity.secret };
|
|
329
|
+
onIdentity?.(identity.secret);
|
|
330
|
+
}
|
|
331
|
+
// The port last used, so an address somebody typed on the other machine
|
|
332
|
+
// still works after this deck restarts. createSyncServer falls through to
|
|
333
|
+
// an OS-chosen one when it is taken, and the caller stores whatever came
|
|
334
|
+
// back — so the pin drifts to a free port rather than failing.
|
|
335
|
+
server = createSyncServer({
|
|
336
|
+
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
337
|
+
name: cfg.name, handlers: serve, onError, prefer: cfg.port,
|
|
338
|
+
trusted: () => cfg.trusted,
|
|
339
|
+
invite: () => (invite && invite.expiresAt > now() ? invite : null),
|
|
340
|
+
// Somebody used the token. They are pinned, and the token is retired —
|
|
341
|
+
// one that pairs twice is one worth stealing twice.
|
|
342
|
+
onInviteUsed: entry => {
|
|
343
|
+
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
|
|
344
|
+
cfg = { ...cfg, trusted: list };
|
|
345
|
+
invite = null;
|
|
346
|
+
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
347
|
+
// deck with no way to reach it: an inbound connection puts nothing in
|
|
348
|
+
// the dial list. Without this the pairing is mutual in the trusted
|
|
349
|
+
// list and one-way in fact — and `addPeer` alone lives in memory, so
|
|
350
|
+
// it would be one-way again after the next restart.
|
|
351
|
+
if (entry.addr && entry.port) {
|
|
352
|
+
this.addPeer(entry.addr, entry.port);
|
|
353
|
+
onDial?.(`${entry.addr}:${entry.port}`);
|
|
354
|
+
// AND SAY WHO IS THERE, NOW. `learned` is what joins a dialled row
|
|
355
|
+
// to a heard one, and it was only ever filled by a round that
|
|
356
|
+
// succeeded — so between accepting a deck and the next round, one
|
|
357
|
+
// machine appeared as two rows. We already know the answer here:
|
|
358
|
+
// the handshake that just finished said so.
|
|
359
|
+
learned.set(`${entry.addr}:${entry.port}`, { fp: entry.fp, name: entry.name || "" });
|
|
360
|
+
}
|
|
361
|
+
onTrust?.(list);
|
|
362
|
+
onChange?.();
|
|
363
|
+
},
|
|
364
|
+
onPending: entry => {
|
|
365
|
+
const had = pending.get(entry.fp);
|
|
366
|
+
pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
|
|
367
|
+
if (!had) onChange?.();
|
|
368
|
+
},
|
|
369
|
+
});
|
|
289
370
|
const port = await server.start();
|
|
371
|
+
if (port !== cfg.port) onPort?.(port);
|
|
290
372
|
beacon = createBeacon({
|
|
291
|
-
port, name: cfg.name, fp: identity.fp,
|
|
373
|
+
port, name: cfg.name, fp: identity.fp,
|
|
374
|
+
trusted: () => cfg.trusted,
|
|
292
375
|
onPeer: () => onChange?.(),
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
376
|
+
onStranger: entry => {
|
|
377
|
+
const had = strangers.get(entry.fp);
|
|
378
|
+
strangers.set(entry.fp, entry);
|
|
379
|
+
// Only a deck that is new to us is news. A beacon every thirty
|
|
380
|
+
// seconds from one already on the list is not a reason to redraw.
|
|
381
|
+
if (!had) onChange?.();
|
|
382
|
+
},
|
|
383
|
+
// Take a new key and keep it. Two decks with one identity are invisible
|
|
384
|
+
// to each other forever otherwise, and the second one to notice moving
|
|
385
|
+
// is enough — whichever notices first, moves.
|
|
296
386
|
onIdClash: () => {
|
|
297
|
-
const fresh =
|
|
298
|
-
onIdentity?.(fresh.
|
|
299
|
-
onError?.("id-clash", new Error("another deck was using this one's
|
|
387
|
+
const fresh = identityFrom("");
|
|
388
|
+
onIdentity?.(fresh.secret);
|
|
389
|
+
onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
|
|
300
390
|
},
|
|
301
391
|
onError, now,
|
|
392
|
+
...(createSocket ? { createSocket } : {}),
|
|
302
393
|
});
|
|
303
394
|
await beacon.start();
|
|
304
395
|
timer = setInterval(() => { void round(); }, SYNC_MS);
|
|
305
396
|
timer.unref?.();
|
|
306
397
|
},
|
|
398
|
+
/**
|
|
399
|
+
* Make an invite: every address this deck has, its port, its name, and a
|
|
400
|
+
* code, in one piece of text somebody sends however they already talk.
|
|
401
|
+
*
|
|
402
|
+
* EVERY ADDRESS, and that is the whole reason this exists. A person cannot
|
|
403
|
+
* know which of their machine's addresses the other machine can route to —
|
|
404
|
+
* a VPN, a second card, another subnet, all real and all at once — and
|
|
405
|
+
* neither can this deck. The one machine that can find out is the one doing
|
|
406
|
+
* the reaching, so it gets the list and tries it.
|
|
407
|
+
*/
|
|
408
|
+
invite() {
|
|
409
|
+
if (!server || !beacon) return null;
|
|
410
|
+
const port = server.port();
|
|
411
|
+
if (port == null) return null;
|
|
412
|
+
const addrs = localAddresses().map(a => `${a}:${port}`);
|
|
413
|
+
const made = mintInvite({ addrs, name: cfg.name, now: now() });
|
|
414
|
+
if (!made) return null;
|
|
415
|
+
invite = made;
|
|
416
|
+
onChange?.();
|
|
417
|
+
return { token: made.token, expiresAt: made.expiresAt, addrs };
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/** What this deck is offering right now, for the panel to draw. Null once
|
|
421
|
+
* it has run out, so a token nobody can use is not shown as if they could. */
|
|
422
|
+
offering() {
|
|
423
|
+
if (!invite || invite.expiresAt <= now()) return null;
|
|
424
|
+
return { token: invite.token, expiresAt: invite.expiresAt };
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
/** Put it away without using it. */
|
|
428
|
+
withdraw() {
|
|
429
|
+
const had = !!invite;
|
|
430
|
+
invite = null;
|
|
431
|
+
if (had) onChange?.();
|
|
432
|
+
return had;
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Join on somebody else's invite: try every address it carries until one
|
|
437
|
+
* answers, and pair with whatever does.
|
|
438
|
+
*
|
|
439
|
+
* IN ORDER, AND STOPPING AT THE FIRST, because the addresses are the same
|
|
440
|
+
* deck seen from different networks — reaching it twice would pair one deck
|
|
441
|
+
* as two. The failures are collected rather than thrown away: when none of
|
|
442
|
+
* them worked, which ones were tried and what each said is the only thing
|
|
443
|
+
* the reader can act on.
|
|
444
|
+
*/
|
|
445
|
+
async join(token) {
|
|
446
|
+
const inv = readInvite(token, now());
|
|
447
|
+
if (!inv) return { ok: false, reason: "not_an_invite" };
|
|
448
|
+
if (inv.expired) return { ok: false, reason: "expired" };
|
|
449
|
+
if (!identity || !server) return { ok: false, reason: "not_running" };
|
|
450
|
+
const tried = [];
|
|
451
|
+
for (const at of inv.addrs) {
|
|
452
|
+
let conn = null;
|
|
453
|
+
try {
|
|
454
|
+
conn = await connectToPeer({
|
|
455
|
+
host: at.addr, port: at.port, timeoutMs: ROUND_MS,
|
|
456
|
+
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
457
|
+
name: cfg.name, myPort: server.port(), code: inv.code,
|
|
458
|
+
});
|
|
459
|
+
const { list } = addTrusted(cfg.trusted, {
|
|
460
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
|
|
461
|
+
});
|
|
462
|
+
cfg = { ...cfg, trusted: list };
|
|
463
|
+
this.addPeer(at.addr, at.port);
|
|
464
|
+
onDial?.(`${at.addr}:${at.port}`);
|
|
465
|
+
learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
|
|
466
|
+
onTrust?.(list);
|
|
467
|
+
onChange?.();
|
|
468
|
+
return {
|
|
469
|
+
ok: true,
|
|
470
|
+
peer: { fp: conn.peerFp, name: conn.peerName || inv.name, addr: at.addr, port: at.port },
|
|
471
|
+
tried,
|
|
472
|
+
};
|
|
473
|
+
} catch (err) {
|
|
474
|
+
tried.push({ addr: `${at.addr}:${at.port}`, why: err.message });
|
|
475
|
+
} finally {
|
|
476
|
+
conn?.sock?.destroy();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return { ok: false, reason: "unreachable", tried };
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Accept a deck, which is the only thing that lets anything move.
|
|
484
|
+
*
|
|
485
|
+
* It takes the fingerprint AND the key that was seen with it, from the
|
|
486
|
+
* pending or heard list — never from whatever is at an address now, because
|
|
487
|
+
* the point of pinning is that the thing answering later has to be the same
|
|
488
|
+
* thing. A fingerprint nobody has actually met is refused rather than
|
|
489
|
+
* trusted on a name somebody typed.
|
|
490
|
+
*/
|
|
491
|
+
accept(fp) {
|
|
492
|
+
const asked = pending.get(fp) ?? null;
|
|
493
|
+
const heard = strangers.get(fp) ?? null;
|
|
494
|
+
const seen = asked ?? heard;
|
|
495
|
+
if (!seen) return null;
|
|
496
|
+
// TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
|
|
497
|
+
//
|
|
498
|
+
// A deck that ASKED finished a handshake, so it held the private half of
|
|
499
|
+
// the key it announced and that key can be pinned right here. A deck we
|
|
500
|
+
// merely HEARD has only shouted: a beacon carries a fingerprint and no
|
|
501
|
+
// key, and pinning a fingerprint with no key to check it against later is
|
|
502
|
+
// worse than not pinning at all — it looks like a pairing and is not one.
|
|
503
|
+
//
|
|
504
|
+
// So accepting a heard deck starts a conversation rather than ending one:
|
|
505
|
+
// its address goes on the dial list, the next round reaches it and pins
|
|
506
|
+
// whatever answers, and its owner gets the same request to accept. Which
|
|
507
|
+
// is the same two presses, in the other order.
|
|
508
|
+
if (!seen.pub) {
|
|
509
|
+
if (!seen.addr || !seen.port) return null;
|
|
510
|
+
this.addPeer(seen.addr, seen.port);
|
|
511
|
+
strangers.delete(fp);
|
|
512
|
+
onChange?.();
|
|
513
|
+
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
514
|
+
}
|
|
515
|
+
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
|
|
516
|
+
cfg = { ...cfg, trusted: list };
|
|
517
|
+
pending.delete(fp);
|
|
518
|
+
strangers.delete(fp);
|
|
519
|
+
onTrust?.(list);
|
|
520
|
+
onChange?.();
|
|
521
|
+
// AND DIAL IT BACK. Accepting a deck that called us made it welcome and
|
|
522
|
+
// left this one with no way to reach it: the peer list is what this deck
|
|
523
|
+
// dials, and an inbound connection puts nothing in it. So the pairing was
|
|
524
|
+
// mutual in the trusted list and one-way in fact — if the other machine
|
|
525
|
+
// stopped calling, nothing here would ever call it. The hello carries the
|
|
526
|
+
// port it listens on for exactly this.
|
|
527
|
+
const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port)
|
|
528
|
+
? (learned.set(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
|
|
529
|
+
{ addr: seen.addr, port: seen.port })
|
|
530
|
+
: null;
|
|
531
|
+
return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
|
|
532
|
+
},
|
|
533
|
+
/** Say no, and stop being asked. The deck is dropped from both lists; if it
|
|
534
|
+
* connects again it is a new request, because refusing is not a block. */
|
|
535
|
+
dismiss(fp) {
|
|
536
|
+
const had = pending.delete(fp) || strangers.delete(fp);
|
|
537
|
+
if (had) onChange?.();
|
|
538
|
+
return had;
|
|
539
|
+
},
|
|
540
|
+
/** Unpair. It stops what has not happened yet and takes back nothing that
|
|
541
|
+
* has — the same sentence the panel says about a shared login. */
|
|
542
|
+
unpair(fp) {
|
|
543
|
+
const list = dropTrusted(cfg.trusted, fp);
|
|
544
|
+
if (list.length === cfg.trusted.length) return false;
|
|
545
|
+
cfg = { ...cfg, trusted: list };
|
|
546
|
+
onTrust?.(list);
|
|
547
|
+
onChange?.();
|
|
548
|
+
return true;
|
|
549
|
+
},
|
|
307
550
|
round,
|
|
308
551
|
/** Dial this address on every round from now on. Returns false for an
|
|
309
552
|
* address that is not one, rather than storing a row that can never
|
|
@@ -340,10 +583,76 @@ export function createEngine({
|
|
|
340
583
|
port: server?.port() ?? null,
|
|
341
584
|
addrs: beacon ? localAddresses() : [],
|
|
342
585
|
shared: [...cfg.shared],
|
|
343
|
-
// The
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
586
|
+
// The token this deck is offering, if any. Drawn as the one thing to do
|
|
587
|
+
// when nobody is paired yet, and put away once somebody is.
|
|
588
|
+
invite: invite && invite.expiresAt > now()
|
|
589
|
+
? { token: invite.token, expiresAt: invite.expiresAt }
|
|
590
|
+
: null,
|
|
591
|
+
// Decks somebody accepted, decks that asked and have not been answered,
|
|
592
|
+
// and decks merely heard. Three lists because they are three different
|
|
593
|
+
// things a person does something different about.
|
|
594
|
+
trusted: cfg.trusted.map(t => ({ fp: t.fp, name: t.name })),
|
|
595
|
+
pending: [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
|
|
596
|
+
// Only the ones somebody could actually pair with right now, one row
|
|
597
|
+
// per machine, newest first — see pairable, which is where the rule
|
|
598
|
+
// that keeps this from becoming a wall of ghosts lives.
|
|
599
|
+
strangers: (() => {
|
|
600
|
+
const { shown, more } = pairable([...strangers.values()], now());
|
|
601
|
+
return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
|
|
602
|
+
})(),
|
|
603
|
+
peers: beacon ? (() => {
|
|
604
|
+
// ONE DECK, ONE ROW, and it takes work because a deck can arrive here
|
|
605
|
+
// twice by two different routes: heard on the network, and dialled at
|
|
606
|
+
// an address somebody typed or that an invite carried. Both are the
|
|
607
|
+
// same machine and neither knows it — the beacon row is keyed by the
|
|
608
|
+
// fingerprint it announced, the typed row by `host:port`, and until a
|
|
609
|
+
// connection succeeds nothing joins them.
|
|
610
|
+
//
|
|
611
|
+
// What joins them is `learned`: the fingerprint that actually
|
|
612
|
+
// answered at that address. So every row is given the identity it is
|
|
613
|
+
// really about, and rows that turn out to share one are merged — the
|
|
614
|
+
// heard half brings liveness, the dialled half brings the last round.
|
|
615
|
+
const rows = [];
|
|
616
|
+
const byId = new Map();
|
|
617
|
+
const put = row => {
|
|
618
|
+
const had = byId.get(row.id);
|
|
619
|
+
if (!had) { byId.set(row.id, row); rows.push(row); return; }
|
|
620
|
+
// Keep what each half is the authority on.
|
|
621
|
+
had.lastSeen = had.lastSeen ?? row.lastSeen;
|
|
622
|
+
had.last = had.last ?? row.last;
|
|
623
|
+
had.manual = had.manual || row.manual;
|
|
624
|
+
had.met = had.met || row.met;
|
|
625
|
+
if (row.name && !had.name) had.name = row.name;
|
|
626
|
+
};
|
|
627
|
+
for (const p of [...beacon.peers.values(), ...manual.values()]) {
|
|
628
|
+
if (!stillListed(p, now())) continue;
|
|
629
|
+
const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
|
|
630
|
+
const id = met?.fp ?? p.fp;
|
|
631
|
+
put({
|
|
632
|
+
...p,
|
|
633
|
+
id,
|
|
634
|
+
// The fingerprint an unpair has to name. A typed row's own `fp` is
|
|
635
|
+
// a placeholder built from its address and matches nothing.
|
|
636
|
+
peerFp: p.manual ? met?.fp ?? null : p.fp,
|
|
637
|
+
name: met?.name || p.name,
|
|
638
|
+
met: !!met,
|
|
639
|
+
paired: !!trustedPeer(cfg.trusted, id),
|
|
640
|
+
last: lastRound.get(p.fp) ?? null,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
// A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
|
|
644
|
+
// it, and nothing here has its address — which used to mean the panel
|
|
645
|
+
// listed failing addresses under "paired decks" and left out the one
|
|
646
|
+
// deck that actually was.
|
|
647
|
+
for (const t of cfg.trusted) {
|
|
648
|
+
if (byId.has(t.fp)) continue;
|
|
649
|
+
put({
|
|
650
|
+
id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
|
|
651
|
+
paired: true, waiting: true, last: lastRound.get(t.fp) ?? null,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return rows;
|
|
655
|
+
})() : [],
|
|
347
656
|
};
|
|
348
657
|
},
|
|
349
658
|
stop() {
|
|
@@ -353,7 +662,6 @@ export function createEngine({
|
|
|
353
662
|
server?.stop();
|
|
354
663
|
beacon = null;
|
|
355
664
|
server = null;
|
|
356
|
-
key = null;
|
|
357
665
|
},
|
|
358
666
|
};
|
|
359
667
|
}
|