agent-dag 3.22.0 → 3.22.3
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
|
@@ -1,952 +0,0 @@
|
|
|
1
|
-
// The thing that actually heals an account: the beacon, the listener and
|
|
2
|
-
// claude-swap, wired together.
|
|
3
|
-
//
|
|
4
|
-
// The two files under this one hold everything that can be reasoned about
|
|
5
|
-
// without a network — lan-sync.mjs decides, lan-socket.mjs carries — and what
|
|
6
|
-
// is left here is the part that has to touch the store. It is deliberately the
|
|
7
|
-
// smallest of the three.
|
|
8
|
-
//
|
|
9
|
-
// WHAT ONE ROUND LOOKS LIKE, from a deck whose copy of an account has died:
|
|
10
|
-
//
|
|
11
|
-
// 1. it hears a beacon from a deck with the same group tag
|
|
12
|
-
// 2. it dials that deck's sync port and both sides prove the passphrase
|
|
13
|
-
// 3. it asks for a manifest: which accounts, and does each one work THERE
|
|
14
|
-
// 4. `plan()` says "heal a@@1" — mine is quarantined, theirs is alive
|
|
15
|
-
// 5. it asks for that one account, with a fresh proof naming it
|
|
16
|
-
// 6. the peer runs `cswap export - --account N`, seals it, sends it
|
|
17
|
-
// 7. it opens the envelope and runs `cswap import -`
|
|
18
|
-
//
|
|
19
|
-
// Steps 6 and 7 are the only ones that touch a credential, and neither of them
|
|
20
|
-
// reads one: claude-swap does the reading and the writing, this passes an
|
|
21
|
-
// opaque blob between two of its commands. That is the same division the manual
|
|
22
|
-
// share already uses, which is why this needed no new credential handling at
|
|
23
|
-
// all.
|
|
24
|
-
//
|
|
25
|
-
// NOTHING HAPPENS ON A SCHEDULE THAT MOVES A CREDENTIAL. The manifest round is
|
|
26
|
-
// periodic and carries no credential; the transfer happens when a plan has
|
|
27
|
-
// something in it, which is when an account is actually broken. A deck whose
|
|
28
|
-
// accounts all work talks to its peers every minute and never asks for
|
|
29
|
-
// anything.
|
|
30
|
-
import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
31
|
-
import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
|
|
32
|
-
import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
|
|
33
|
-
import { openAbout, sealAbout } from "./lan-about.mjs";
|
|
34
|
-
import { randomBytes } from "node:crypto";
|
|
35
|
-
import { hostname, networkInterfaces } from "node:os";
|
|
36
|
-
|
|
37
|
-
/** How often a deck asks its peers what they have. A minute is far more often
|
|
38
|
-
* than a login dies, and it is what makes the panel's list feel live rather
|
|
39
|
-
* than something that updates when you press a button. */
|
|
40
|
-
export const SYNC_MS = 60_000;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* How long to wait before dialling again while somebody is deciding.
|
|
44
|
-
*
|
|
45
|
-
* A minute is right for the steady state — two decks whose logins all work have
|
|
46
|
-
* nothing to say to each other — and it is far too long for the one moment
|
|
47
|
-
* anybody is watching: the seconds after somebody presses accept on the other
|
|
48
|
-
* machine. Reported as "it should work by itself", from a panel that had been
|
|
49
|
-
* correct for up to fifty-nine more seconds than the person in front of it.
|
|
50
|
-
*
|
|
51
|
-
* So the loop tightens while a request is outstanding and relaxes the moment it
|
|
52
|
-
* is answered — either way. A refusal is an answer, and a deck that said no is
|
|
53
|
-
* not asked every eight seconds.
|
|
54
|
-
*/
|
|
55
|
-
export const ASKING_MS = 8_000;
|
|
56
|
-
|
|
57
|
-
/** How long one peer round may take before it is abandoned. A manifest is one
|
|
58
|
-
* round trip on a local network; anything past this is a peer that is not
|
|
59
|
-
* going to answer, and holding the attempt open would stall the next round. */
|
|
60
|
-
export const ROUND_MS = 10_000;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
/** What this machine calls itself when the user has not said. The hostname,
|
|
64
|
-
* because that is the word they already use for this machine everywhere else. */
|
|
65
|
-
export function defaultName() {
|
|
66
|
-
return hostname().replace(/\.local$/i, "") || "this machine";
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* EVERY address another deck might dial, for the panel to print.
|
|
71
|
-
*
|
|
72
|
-
* It returned the first one, and that was wrong the first time somebody
|
|
73
|
-
* checked: on this machine the first is 192.168.1.82 and the deck it needs to
|
|
74
|
-
* reach is on Tailscale at 100.67.32.58, so the panel would have offered an
|
|
75
|
-
* address that peer cannot route to and left them to work out why.
|
|
76
|
-
*
|
|
77
|
-
* WHICH ONE IS RIGHT DEPENDS ON WHERE THE PEER IS, which this side cannot
|
|
78
|
-
* answer — a VPN, a second NIC, a container bridge, all real and all at once.
|
|
79
|
-
* So it offers them all and the person picks: they are the only one who knows
|
|
80
|
-
* how the other machine sees this one, and a list of two is a smaller ask than
|
|
81
|
-
* a wrong answer.
|
|
82
|
-
*
|
|
83
|
-
* `internal` is node's word for loopback, and a link-local 169.254 address is a
|
|
84
|
-
* machine whose DHCP failed — reachable by nobody worth telling about.
|
|
85
|
-
*/
|
|
86
|
-
export function localAddresses(faces = networkInterfaces()) {
|
|
87
|
-
const out = [];
|
|
88
|
-
for (const list of Object.values(faces ?? {})) {
|
|
89
|
-
for (const n of list ?? []) {
|
|
90
|
-
if (n.internal) continue;
|
|
91
|
-
if (n.family !== "IPv4" && n.family !== 4) continue;
|
|
92
|
-
if (typeof n.address !== "string" || n.address.startsWith("169.254.")) continue;
|
|
93
|
-
if (!out.includes(n.address)) out.push(n.address);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return out;
|
|
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
|
-
|
|
113
|
-
/**
|
|
114
|
-
* One deck's LAN sync, from settings to a healed account.
|
|
115
|
-
*
|
|
116
|
-
* `deps` is every side effect: reading accounts, exporting one, importing one.
|
|
117
|
-
* Injected rather than imported so a test can run a whole round — two engines,
|
|
118
|
-
* two fake stores, one real socket pair — without claude-swap on the machine.
|
|
119
|
-
*/
|
|
120
|
-
export function createEngine({
|
|
121
|
-
readAccounts, exportAccount, importAccount,
|
|
122
|
-
onChange, onError, onIdentity, onPort, onTrust, onDial, now = Date.now,
|
|
123
|
-
/**
|
|
124
|
-
* The UDP socket the beacon shouts through, injectable for the same reason
|
|
125
|
-
* lan-socket exposes it — and for one more that only showed up in use.
|
|
126
|
-
*
|
|
127
|
-
* The suite runs whole engines over real sockets, which is right: a handshake
|
|
128
|
-
* between two of them is the thing being tested and a mock would only check
|
|
129
|
-
* that the mock agrees with the code it was written from. But `createBeacon`
|
|
130
|
-
* defaulted to a real dgram socket, so `npm test` BROADCAST on whatever
|
|
131
|
-
* network the machine was on — and the fake decks it announces turned up in
|
|
132
|
-
* a real panel, on a real screen, in a list of decks somebody could pair
|
|
133
|
-
* with. Nothing secret leaves, and it is still a test shouting at an office.
|
|
134
|
-
*/
|
|
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,
|
|
139
|
-
} = {}) {
|
|
140
|
-
let cfg = {
|
|
141
|
-
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
142
|
-
autoAsk: true, autoAccept: true, aliases: {},
|
|
143
|
-
};
|
|
144
|
-
let identity = null;
|
|
145
|
-
let beacon = null;
|
|
146
|
-
let server = null;
|
|
147
|
-
let timer = null;
|
|
148
|
-
/**
|
|
149
|
-
* Decks that finished a handshake and that nobody here has accepted yet, and
|
|
150
|
-
* decks merely heard shouting on the network. Two lists because they are two
|
|
151
|
-
* different claims: a pending deck proved it holds the key it announced, a
|
|
152
|
-
* heard one only said so. Both are rows with an accept on them; only the
|
|
153
|
-
* first is evidence.
|
|
154
|
-
*
|
|
155
|
-
* In memory rather than on disk. A request that is a day old is not a request
|
|
156
|
-
* any more, and a list of them that survives restarts is a list nobody reads.
|
|
157
|
-
*/
|
|
158
|
-
const pending = new Map();
|
|
159
|
-
const strangers = new Map();
|
|
160
|
-
/**
|
|
161
|
-
* Decks somebody here said no to.
|
|
162
|
-
*
|
|
163
|
-
* WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
|
|
164
|
-
* that keeps asking — it dials on its own timer, and every dial that finds no
|
|
165
|
-
* pin here becomes a fresh request. So `dismiss` took a row off a list that
|
|
166
|
-
* the next minute put back, and on the other machine the refusal was
|
|
167
|
-
* indistinguishable from a deck that had not been answered yet: both are the
|
|
168
|
-
* same `pending` refusal on the wire, and both drew "waiting for the other
|
|
169
|
-
* deck to accept this one" forever.
|
|
170
|
-
*
|
|
171
|
-
* A name kept here is therefore two answers at once. This deck stops asking
|
|
172
|
-
* its owner, and the deck that asked is TOLD — see refuse("declined") in
|
|
173
|
-
* lan-socket.mjs, which is the only way the far end can ever learn that the
|
|
174
|
-
* answer was no rather than not yet.
|
|
175
|
-
*
|
|
176
|
-
* In memory, like the two lists above, and reversible: `allow` takes a name
|
|
177
|
-
* out and the requests come back. Nothing about it is written down, because a
|
|
178
|
-
* refusal that outlives the process is a decision nobody can find to undo.
|
|
179
|
-
*/
|
|
180
|
-
const declined = new Map();
|
|
181
|
-
/** The invite this deck is offering, or null. One at a time: a deck showing
|
|
182
|
-
* two tokens is a deck whose owner cannot say which one they sent. */
|
|
183
|
-
let invite = null;
|
|
184
|
-
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
185
|
-
* recent only, because "what happened" is a question about now. */
|
|
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();
|
|
197
|
-
/** When the last round FINISHED, whatever it did or failed to do. The panel's
|
|
198
|
-
* `↻` fires one on demand and the loop fires one on its own; a reader who
|
|
199
|
-
* pressed it wants to know it happened, and a reader who did not wants to
|
|
200
|
-
* know the list is not a photograph of an hour ago. */
|
|
201
|
-
let roundAt = null;
|
|
202
|
-
/**
|
|
203
|
-
* Why this deck is not listening, when it is switched on and is not.
|
|
204
|
-
*
|
|
205
|
-
* A second deck on one machine takes the first one's port and the bind fails;
|
|
206
|
-
* the switch stays on, the beacon never starts, and the panel drew
|
|
207
|
-
* `starting…` for as long as the process lived. A state that cannot resolve
|
|
208
|
-
* and does not say why is the worst thing an instrument can show — the reader
|
|
209
|
-
* waits, and waiting is the one thing that never fixes it.
|
|
210
|
-
*
|
|
211
|
-
* Only the failures that stop the service reach this. A round that could not
|
|
212
|
-
* reach one peer is that peer's row, not the deck's.
|
|
213
|
-
*/
|
|
214
|
-
let stalled = null;
|
|
215
|
-
/**
|
|
216
|
-
* When each paired deck last SPOKE TO THIS ONE, keyed by fingerprint.
|
|
217
|
-
*
|
|
218
|
-
* The panel had no evidence at all about a deck it does not dial. A deck that
|
|
219
|
-
* calls in has no beacon row here (if it had one it would be dialled), never
|
|
220
|
-
* appears in `lastRound`, and its `lastSeen` was therefore undefined forever
|
|
221
|
-
* — so the row was drawn as live on the strength of being paired, and a
|
|
222
|
-
* Windows deck that had been closed for an hour still read `ready`. Reported
|
|
223
|
-
* from a screenshot of exactly that.
|
|
224
|
-
*
|
|
225
|
-
* Every authenticated frame lands in `serve`, which is the one place that
|
|
226
|
-
* knows a paired deck is on the other end of an open socket right now. That
|
|
227
|
-
* is the evidence, and it is the same kind the beacon gives: a timestamp.
|
|
228
|
-
*/
|
|
229
|
-
const spokeAt = new Map();
|
|
230
|
-
|
|
231
|
-
/** This deck's accounts in the shape the rules want. Read through the same
|
|
232
|
-
* function the panel uses, so a row can never be alive here and dead there. */
|
|
233
|
-
const localAccounts = async () => {
|
|
234
|
-
const got = await readAccounts();
|
|
235
|
-
return (got?.accounts ?? []).map(a => ({
|
|
236
|
-
key: accountKey(a.email, a.orgUuid),
|
|
237
|
-
email: a.email,
|
|
238
|
-
alive: a.alive === true,
|
|
239
|
-
num: a.num,
|
|
240
|
-
}));
|
|
241
|
-
};
|
|
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
|
-
|
|
252
|
-
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
253
|
-
* accepted. Nothing reaches this before both, which is the whole point of
|
|
254
|
-
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
255
|
-
* connection's — see sessionKey. */
|
|
256
|
-
const serve = async (msg, ctx) => {
|
|
257
|
-
// Before the verbs, and for every one of them: something that proved it
|
|
258
|
-
// holds a key this deck accepted is talking, now.
|
|
259
|
-
if (ctx?.peerFp) spokeAt.set(ctx.peerFp, now());
|
|
260
|
-
try {
|
|
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() });
|
|
267
|
-
const accounts = await localAccounts();
|
|
268
|
-
return ctx.send({
|
|
269
|
-
t: "manifest", accounts: manifestFor(accounts, cfg.shared),
|
|
270
|
-
...cardFor(ctx.key, ctx.peerFp),
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
if (msg.t === "want") {
|
|
274
|
-
// A SECOND PROOF, for the one operation that moves a credential. The
|
|
275
|
-
// session says who connected; this says they are asking for this
|
|
276
|
-
// account, now. A long-lived connection authenticated an hour ago is
|
|
277
|
-
// not a statement about now.
|
|
278
|
-
const want = transferChallenge(ctx.key, {
|
|
279
|
-
nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
|
|
280
|
-
});
|
|
281
|
-
if (typeof msg.proof !== "string" || msg.proof !== want) {
|
|
282
|
-
return ctx.send({ t: "no", why: "proof" });
|
|
283
|
-
}
|
|
284
|
-
// Only what the user ticked, checked again here rather than trusted
|
|
285
|
-
// from the manifest we sent: the list can change between the two, and
|
|
286
|
-
// the answer that matters is the one at the moment of sending.
|
|
287
|
-
if (!cfg.shared.includes(msg.key)) return ctx.send({ t: "no", why: "not shared" });
|
|
288
|
-
const accounts = await localAccounts();
|
|
289
|
-
const mine = accounts.find(a => a.key === msg.key);
|
|
290
|
-
if (!mine || !mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
|
|
291
|
-
const blob = await exportAccount(mine.num);
|
|
292
|
-
if (!blob) return ctx.send({ t: "no", why: "export failed" });
|
|
293
|
-
const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
|
|
294
|
-
return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
|
|
295
|
-
}
|
|
296
|
-
} catch (err) {
|
|
297
|
-
onError?.("serve", err);
|
|
298
|
-
ctx.send({ t: "no", why: "error" });
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Is anybody on the other end still deciding?
|
|
304
|
-
*
|
|
305
|
-
* The exact sentence lan-socket.mjs sends for "a real deck, not yet
|
|
306
|
-
* accepted", which is the one state where dialling again in a few seconds
|
|
307
|
-
* does something a minute later would not.
|
|
308
|
-
*/
|
|
309
|
-
const waitingOnSomebody = () =>
|
|
310
|
-
[...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
|
|
311
|
-
|
|
312
|
-
/** Ask one peer what it has, and heal whatever it can heal. */
|
|
313
|
-
const roundWith = async peer => {
|
|
314
|
-
let conn = null;
|
|
315
|
-
try {
|
|
316
|
-
conn = await connectToPeer({
|
|
317
|
-
host: peer.addr, port: peer.port, timeoutMs: ROUND_MS,
|
|
318
|
-
fp: identity.fp, pub: identity.pub, secret: identity.secret, name: cfg.name,
|
|
319
|
-
// Where this deck listens, so the far side can reach back after it
|
|
320
|
-
// accepts rather than only being reachable.
|
|
321
|
-
myPort: server?.port() ?? null,
|
|
322
|
-
// The key pinned when this deck was accepted, so a second machine
|
|
323
|
-
// answering at that address is refused rather than talked to.
|
|
324
|
-
expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
|
|
325
|
-
});
|
|
326
|
-
const ask = frame => new Promise((resolve, reject) => {
|
|
327
|
-
const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
|
|
328
|
-
bell.unref?.();
|
|
329
|
-
let buf = "";
|
|
330
|
-
const onData = chunk => {
|
|
331
|
-
buf += chunk;
|
|
332
|
-
const i = buf.indexOf("\n");
|
|
333
|
-
if (i === -1) return;
|
|
334
|
-
clearTimeout(bell);
|
|
335
|
-
conn.sock.off("data", onData);
|
|
336
|
-
try { resolve(JSON.parse(buf.slice(0, i))); } catch { reject(new Error("bad reply")); }
|
|
337
|
-
};
|
|
338
|
-
conn.sock.on("data", onData);
|
|
339
|
-
sendFrame(conn.sock, frame);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY TYPED. Reaching a
|
|
343
|
-
// deck we have no pin for means the person at this keyboard put its
|
|
344
|
-
// address in the field, which is the same decision the accept button is
|
|
345
|
-
// on the other side. Pinning it here is what makes the two lists agree —
|
|
346
|
-
// without it this deck would dial a peer every minute and still show it
|
|
347
|
-
// as nobody, and its own listener would refuse the same deck calling
|
|
348
|
-
// back.
|
|
349
|
-
//
|
|
350
|
-
// A deck we DO have a pin for was checked before this line: connectToPeer
|
|
351
|
-
// was given expectPub and refuses a different key at that address.
|
|
352
|
-
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
353
|
-
// and nothing else until somebody answers it — and once one has, the deck
|
|
354
|
-
// on the other end has told us what it calls itself. The row says that
|
|
355
|
-
// from then on, because "Constantin-PC" is what the person who typed the
|
|
356
|
-
// address was trying to reach.
|
|
357
|
-
learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
|
|
358
|
-
|
|
359
|
-
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
360
|
-
const { list, added } = addTrusted(cfg.trusted, {
|
|
361
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
|
|
362
|
-
});
|
|
363
|
-
if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
|
|
364
|
-
}
|
|
365
|
-
|
|
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) });
|
|
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) });
|
|
373
|
-
const mine = await localAccounts();
|
|
374
|
-
// Only accounts I have also ticked. Sharing is mutual by construction:
|
|
375
|
-
// a peer cannot push an account at me that I never agreed to hold.
|
|
376
|
-
// A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
|
|
377
|
-
// Healing replaces a slot I already have, so it is only reasonable for an
|
|
378
|
-
// account I said I share. Adding is the case the owner asked for by name:
|
|
379
|
-
// an account that appears among the decks I paired with appears on all of
|
|
380
|
-
// them, which is the whole of "I do not want to paste blobs any more".
|
|
381
|
-
// What can reach this is what a deck somebody here pressed accept on
|
|
382
|
-
// chose to offer.
|
|
383
|
-
const wanted = plan(mine, theirs.accounts)
|
|
384
|
-
.filter(step => step.action === "add" || cfg.shared.includes(step.key));
|
|
385
|
-
const done = [];
|
|
386
|
-
for (const step of wanted) {
|
|
387
|
-
const nonce = randomBytes(12).toString("hex");
|
|
388
|
-
const reply = await ask({
|
|
389
|
-
t: "want", key: step.key, nonce,
|
|
390
|
-
proof: transferChallenge(conn.key, {
|
|
391
|
-
nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
|
|
392
|
-
}),
|
|
393
|
-
});
|
|
394
|
-
if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
|
|
395
|
-
const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
|
|
396
|
-
if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
|
|
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") });
|
|
407
|
-
}
|
|
408
|
-
lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
|
|
409
|
-
if (done.length) onChange?.();
|
|
410
|
-
return done;
|
|
411
|
-
} catch (err) {
|
|
412
|
-
lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
|
|
413
|
-
return [];
|
|
414
|
-
} finally {
|
|
415
|
-
conn?.sock?.destroy();
|
|
416
|
-
}
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
/** Peers the user typed in, which the beacon will never find.
|
|
420
|
-
*
|
|
421
|
-
* Broadcast dies at the first router and is dropped by a switch that
|
|
422
|
-
* filters it, so a deck across a VPN or on another subnet is unreachable by
|
|
423
|
-
* discovery and perfectly reachable by address. Typing one is a decision to
|
|
424
|
-
* trust whatever answers there the first time, and to pin it: an address is
|
|
425
|
-
* a way to reach a deck, and the accept on the other machine is what lets
|
|
426
|
-
* anything move.
|
|
427
|
-
*
|
|
428
|
-
* Keyed by `host:port` rather than by fingerprint, because a fingerprint is
|
|
429
|
-
* what a deck says about itself after the handshake and this list has to
|
|
430
|
-
* exist before there has been one. */
|
|
431
|
-
const manual = new Map();
|
|
432
|
-
/** What answered at a typed address, once something has. Keyed the same way
|
|
433
|
-
* `manual` is, because until a connection succeeds an address is all there
|
|
434
|
-
* is to key on. */
|
|
435
|
-
const learned = new Map();
|
|
436
|
-
|
|
437
|
-
const round = async () => {
|
|
438
|
-
if (!beacon) return [];
|
|
439
|
-
const all = [];
|
|
440
|
-
// Heard first, typed second, and a typed one is skipped when the beacon
|
|
441
|
-
// already found that address: otherwise a deck that is both would be dialled
|
|
442
|
-
// twice a round and its work counted twice.
|
|
443
|
-
// The same rule the list uses. A deck that has been silent for a day is not
|
|
444
|
-
// dialled once a minute forever on the chance it comes back.
|
|
445
|
-
const heard = [...beacon.peers.values()].filter(p => stillListed(p, now()));
|
|
446
|
-
const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
|
|
447
|
-
for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
|
|
448
|
-
// Sequential rather than parallel. The store takes one mutation at a
|
|
449
|
-
// time anyway (cswap-admin's lock), and two peers healing the same
|
|
450
|
-
// account at once would race for a slot number claude-swap assigns as
|
|
451
|
-
// max+1 without a lock of its own.
|
|
452
|
-
all.push(...await roundWith(peer));
|
|
453
|
-
}
|
|
454
|
-
roundAt = now();
|
|
455
|
-
return all;
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
return {
|
|
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;
|
|
463
|
-
const was = cfg;
|
|
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
|
-
}
|
|
477
|
-
const restart = !was.enabled !== !cfg.enabled
|
|
478
|
-
|| was.secret !== cfg.secret
|
|
479
|
-
|| was.name !== cfg.name;
|
|
480
|
-
if (!restart) return;
|
|
481
|
-
this.stop();
|
|
482
|
-
if (!cfg.enabled) return;
|
|
483
|
-
identity = identityFrom(cfg.secret);
|
|
484
|
-
// Hand the caller a key to keep when there was none, so the next start is
|
|
485
|
-
// the same deck rather than a stranger to everybody who paired with it.
|
|
486
|
-
if (identity.secret !== cfg.secret) {
|
|
487
|
-
cfg = { ...cfg, secret: identity.secret };
|
|
488
|
-
onIdentity?.(identity.secret);
|
|
489
|
-
}
|
|
490
|
-
// The port last used, so an address somebody typed on the other machine
|
|
491
|
-
// still works after this deck restarts. createSyncServer falls through to
|
|
492
|
-
// an OS-chosen one when it is taken, and the caller stores whatever came
|
|
493
|
-
// back — so the pin drifts to a free port rather than failing.
|
|
494
|
-
server = createSyncServer({
|
|
495
|
-
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
496
|
-
name: cfg.name, handlers: serve, onError, prefer: cfg.port,
|
|
497
|
-
trusted: () => cfg.trusted,
|
|
498
|
-
invite: () => (invite && invite.expiresAt > now() ? invite : null),
|
|
499
|
-
// Somebody used the token. They are pinned, and the token is retired —
|
|
500
|
-
// one that pairs twice is one worth stealing twice.
|
|
501
|
-
onInviteUsed: entry => {
|
|
502
|
-
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
|
|
503
|
-
cfg = { ...cfg, trusted: list };
|
|
504
|
-
invite = null;
|
|
505
|
-
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
506
|
-
// deck with no way to reach it: an inbound connection puts nothing in
|
|
507
|
-
// the dial list. Without this the pairing is mutual in the trusted
|
|
508
|
-
// list and one-way in fact — and `addPeer` alone lives in memory, so
|
|
509
|
-
// it would be one-way again after the next restart.
|
|
510
|
-
if (entry.addr && entry.port) {
|
|
511
|
-
this.addPeer(entry.addr, entry.port);
|
|
512
|
-
onDial?.(`${entry.addr}:${entry.port}`);
|
|
513
|
-
// AND SAY WHO IS THERE, NOW. `learned` is what joins a dialled row
|
|
514
|
-
// to a heard one, and it was only ever filled by a round that
|
|
515
|
-
// succeeded — so between accepting a deck and the next round, one
|
|
516
|
-
// machine appeared as two rows. We already know the answer here:
|
|
517
|
-
// the handshake that just finished said so.
|
|
518
|
-
learned.set(`${entry.addr}:${entry.port}`, { fp: entry.fp, name: entry.name || "" });
|
|
519
|
-
}
|
|
520
|
-
onTrust?.(list);
|
|
521
|
-
onChange?.();
|
|
522
|
-
},
|
|
523
|
-
// Asked before the request is drawn, so a deck that was told no is
|
|
524
|
-
// told no again rather than becoming a row somebody has to answer
|
|
525
|
-
// twice. The socket sends the reason; this only knows the name.
|
|
526
|
-
declined: fp => declined.has(fp),
|
|
527
|
-
onPending: entry => {
|
|
528
|
-
const had = pending.get(entry.fp);
|
|
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; }
|
|
540
|
-
if (!had) onChange?.();
|
|
541
|
-
},
|
|
542
|
-
});
|
|
543
|
-
let port;
|
|
544
|
-
try {
|
|
545
|
-
port = await server.start();
|
|
546
|
-
} catch (err) {
|
|
547
|
-
// Kept, so the panel can say it. Rethrown, because the caller's own
|
|
548
|
-
// catch is what leaves the engine stopped rather than half-started.
|
|
549
|
-
stalled = err?.message ?? String(err);
|
|
550
|
-
throw err;
|
|
551
|
-
}
|
|
552
|
-
stalled = null;
|
|
553
|
-
if (port !== cfg.port) onPort?.(port);
|
|
554
|
-
beacon = createBeacon({
|
|
555
|
-
port, name: cfg.name, fp: identity.fp,
|
|
556
|
-
trusted: () => cfg.trusted,
|
|
557
|
-
onPeer: () => onChange?.(),
|
|
558
|
-
onStranger: entry => {
|
|
559
|
-
const had = strangers.get(entry.fp);
|
|
560
|
-
// KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
|
|
561
|
-
// a fresh key — a second deck sharing one config directory does, by
|
|
562
|
-
// design — used to leave its old key in this map for a day, and every
|
|
563
|
-
// one of them drew a row offering to pair with the same machine.
|
|
564
|
-
if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
|
|
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; }
|
|
576
|
-
// Only a deck that is new to us is news. A beacon every thirty
|
|
577
|
-
// seconds from one already on the list is not a reason to redraw.
|
|
578
|
-
if (!had) onChange?.();
|
|
579
|
-
},
|
|
580
|
-
// Take a new key and keep it. Two decks with one identity are invisible
|
|
581
|
-
// to each other forever otherwise, and the second one to notice moving
|
|
582
|
-
// is enough — whichever notices first, moves.
|
|
583
|
-
onIdClash: () => {
|
|
584
|
-
const fresh = identityFrom("");
|
|
585
|
-
onIdentity?.(fresh.secret);
|
|
586
|
-
onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
|
|
587
|
-
},
|
|
588
|
-
onError, now,
|
|
589
|
-
...(createSocket ? { createSocket } : {}),
|
|
590
|
-
});
|
|
591
|
-
await beacon.start();
|
|
592
|
-
// A self-scheduling loop rather than one interval, because the gap
|
|
593
|
-
// between rounds is not one number: see ASKING_MS.
|
|
594
|
-
const tick = async () => {
|
|
595
|
-
try { await round(); } catch { /* a round reports itself, per peer */ }
|
|
596
|
-
if (!beacon) return;
|
|
597
|
-
timer = setTimeout(() => { void tick(); }, waitingOnSomebody() ? ASKING_MS : SYNC_MS);
|
|
598
|
-
timer.unref?.();
|
|
599
|
-
};
|
|
600
|
-
timer = setTimeout(() => { void tick(); }, SYNC_MS);
|
|
601
|
-
timer.unref?.();
|
|
602
|
-
},
|
|
603
|
-
/**
|
|
604
|
-
* Make an invite: every address this deck has, its port, its name, and a
|
|
605
|
-
* code, in one piece of text somebody sends however they already talk.
|
|
606
|
-
*
|
|
607
|
-
* EVERY ADDRESS, and that is the whole reason this exists. A person cannot
|
|
608
|
-
* know which of their machine's addresses the other machine can route to —
|
|
609
|
-
* a VPN, a second card, another subnet, all real and all at once — and
|
|
610
|
-
* neither can this deck. The one machine that can find out is the one doing
|
|
611
|
-
* the reaching, so it gets the list and tries it.
|
|
612
|
-
*/
|
|
613
|
-
invite() {
|
|
614
|
-
if (!server || !beacon) return null;
|
|
615
|
-
const port = server.port();
|
|
616
|
-
if (port == null) return null;
|
|
617
|
-
const addrs = localAddresses().map(a => `${a}:${port}`);
|
|
618
|
-
const made = mintInvite({ addrs, name: cfg.name, now: now() });
|
|
619
|
-
if (!made) return null;
|
|
620
|
-
invite = made;
|
|
621
|
-
onChange?.();
|
|
622
|
-
return { token: made.token, expiresAt: made.expiresAt, addrs };
|
|
623
|
-
},
|
|
624
|
-
|
|
625
|
-
/** What this deck is offering right now, for the panel to draw. Null once
|
|
626
|
-
* it has run out, so a token nobody can use is not shown as if they could. */
|
|
627
|
-
offering() {
|
|
628
|
-
if (!invite || invite.expiresAt <= now()) return null;
|
|
629
|
-
return { token: invite.token, expiresAt: invite.expiresAt };
|
|
630
|
-
},
|
|
631
|
-
|
|
632
|
-
/** Put it away without using it. */
|
|
633
|
-
withdraw() {
|
|
634
|
-
const had = !!invite;
|
|
635
|
-
invite = null;
|
|
636
|
-
if (had) onChange?.();
|
|
637
|
-
return had;
|
|
638
|
-
},
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Join on somebody else's invite: try every address it carries until one
|
|
642
|
-
* answers, and pair with whatever does.
|
|
643
|
-
*
|
|
644
|
-
* IN ORDER, AND STOPPING AT THE FIRST, because the addresses are the same
|
|
645
|
-
* deck seen from different networks — reaching it twice would pair one deck
|
|
646
|
-
* as two. The failures are collected rather than thrown away: when none of
|
|
647
|
-
* them worked, which ones were tried and what each said is the only thing
|
|
648
|
-
* the reader can act on.
|
|
649
|
-
*/
|
|
650
|
-
async join(token) {
|
|
651
|
-
const inv = readInvite(token, now());
|
|
652
|
-
if (!inv) return { ok: false, reason: "not_an_invite" };
|
|
653
|
-
if (inv.expired) return { ok: false, reason: "expired" };
|
|
654
|
-
if (!identity || !server) return { ok: false, reason: "not_running" };
|
|
655
|
-
const tried = [];
|
|
656
|
-
for (const at of inv.addrs) {
|
|
657
|
-
let conn = null;
|
|
658
|
-
try {
|
|
659
|
-
conn = await connectToPeer({
|
|
660
|
-
host: at.addr, port: at.port, timeoutMs: ROUND_MS,
|
|
661
|
-
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
662
|
-
name: cfg.name, myPort: server.port(), code: inv.code,
|
|
663
|
-
});
|
|
664
|
-
const { list } = addTrusted(cfg.trusted, {
|
|
665
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
666
|
-
});
|
|
667
|
-
cfg = { ...cfg, trusted: list };
|
|
668
|
-
this.addPeer(at.addr, at.port);
|
|
669
|
-
onDial?.(`${at.addr}:${at.port}`);
|
|
670
|
-
learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
|
|
671
|
-
onTrust?.(list);
|
|
672
|
-
onChange?.();
|
|
673
|
-
return {
|
|
674
|
-
ok: true,
|
|
675
|
-
peer: { fp: conn.peerFp, name: conn.peerName || inv.name, addr: at.addr, port: at.port },
|
|
676
|
-
tried,
|
|
677
|
-
};
|
|
678
|
-
} catch (err) {
|
|
679
|
-
tried.push({ addr: `${at.addr}:${at.port}`, why: err.message });
|
|
680
|
-
} finally {
|
|
681
|
-
conn?.sock?.destroy();
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return { ok: false, reason: "unreachable", tried };
|
|
685
|
-
},
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* Accept a deck, which is the only thing that lets anything move.
|
|
689
|
-
*
|
|
690
|
-
* It takes the fingerprint AND the key that was seen with it, from the
|
|
691
|
-
* pending or heard list — never from whatever is at an address now, because
|
|
692
|
-
* the point of pinning is that the thing answering later has to be the same
|
|
693
|
-
* thing. A fingerprint nobody has actually met is refused rather than
|
|
694
|
-
* trusted on a name somebody typed.
|
|
695
|
-
*/
|
|
696
|
-
accept(fp) {
|
|
697
|
-
const asked = pending.get(fp) ?? null;
|
|
698
|
-
const heard = strangers.get(fp) ?? null;
|
|
699
|
-
const seen = asked ?? heard;
|
|
700
|
-
if (!seen) return null;
|
|
701
|
-
// TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
|
|
702
|
-
//
|
|
703
|
-
// A deck that ASKED finished a handshake, so it held the private half of
|
|
704
|
-
// the key it announced and that key can be pinned right here. A deck we
|
|
705
|
-
// merely HEARD has only shouted: a beacon carries a fingerprint and no
|
|
706
|
-
// key, and pinning a fingerprint with no key to check it against later is
|
|
707
|
-
// worse than not pinning at all — it looks like a pairing and is not one.
|
|
708
|
-
//
|
|
709
|
-
// So accepting a heard deck starts a conversation rather than ending one:
|
|
710
|
-
// its address goes on the dial list, the next round reaches it and pins
|
|
711
|
-
// whatever answers, and its owner gets the same request to accept. Which
|
|
712
|
-
// is the same two presses, in the other order.
|
|
713
|
-
if (!seen.pub) {
|
|
714
|
-
if (!seen.addr || !seen.port) return null;
|
|
715
|
-
this.addPeer(seen.addr, seen.port);
|
|
716
|
-
strangers.delete(fp);
|
|
717
|
-
onChange?.();
|
|
718
|
-
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
719
|
-
}
|
|
720
|
-
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
|
|
721
|
-
cfg = { ...cfg, trusted: list };
|
|
722
|
-
pending.delete(fp);
|
|
723
|
-
strangers.delete(fp);
|
|
724
|
-
onTrust?.(list);
|
|
725
|
-
onChange?.();
|
|
726
|
-
// AND DIAL IT BACK. Accepting a deck that called us made it welcome and
|
|
727
|
-
// left this one with no way to reach it: the peer list is what this deck
|
|
728
|
-
// dials, and an inbound connection puts nothing in it. So the pairing was
|
|
729
|
-
// mutual in the trusted list and one-way in fact — if the other machine
|
|
730
|
-
// stopped calling, nothing here would ever call it. The hello carries the
|
|
731
|
-
// port it listens on for exactly this.
|
|
732
|
-
const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port)
|
|
733
|
-
? (learned.set(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
|
|
734
|
-
{ addr: seen.addr, port: seen.port })
|
|
735
|
-
: null;
|
|
736
|
-
return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
|
|
737
|
-
},
|
|
738
|
-
/** Say no, and stop being asked. The deck is dropped from both lists; if it
|
|
739
|
-
* connects again it is a new request, because refusing is not a block. */
|
|
740
|
-
dismiss(fp) {
|
|
741
|
-
// Whatever the row said, kept — the panel draws a declined deck by name
|
|
742
|
-
// and address, and after the delete below there is nowhere else to read
|
|
743
|
-
// them from.
|
|
744
|
-
const was = pending.get(fp) ?? strangers.get(fp) ?? null;
|
|
745
|
-
const had = pending.delete(fp) || strangers.delete(fp);
|
|
746
|
-
if (had) {
|
|
747
|
-
declined.set(fp, {
|
|
748
|
-
fp,
|
|
749
|
-
name: was?.name ?? fp,
|
|
750
|
-
addr: was?.addr ?? "",
|
|
751
|
-
port: was?.port ?? 0,
|
|
752
|
-
at: now(),
|
|
753
|
-
});
|
|
754
|
-
onChange?.();
|
|
755
|
-
}
|
|
756
|
-
return had;
|
|
757
|
-
},
|
|
758
|
-
/** Change your mind. The name comes off the declined list and the next time
|
|
759
|
-
* that deck dials, it is a request again — which it will, on its own, so
|
|
760
|
-
* there is nothing else to press. */
|
|
761
|
-
allow(fp) {
|
|
762
|
-
const had = declined.delete(fp);
|
|
763
|
-
if (had) onChange?.();
|
|
764
|
-
return had;
|
|
765
|
-
},
|
|
766
|
-
/** Unpair. It stops what has not happened yet and takes back nothing that
|
|
767
|
-
* has — the same sentence the panel says about a shared login. */
|
|
768
|
-
unpair(fp) {
|
|
769
|
-
const list = dropTrusted(cfg.trusted, fp);
|
|
770
|
-
if (list.length === cfg.trusted.length) return false;
|
|
771
|
-
cfg = { ...cfg, trusted: list };
|
|
772
|
-
onTrust?.(list);
|
|
773
|
-
onChange?.();
|
|
774
|
-
return true;
|
|
775
|
-
},
|
|
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
|
-
},
|
|
795
|
-
/** Dial this address on every round from now on. Returns false for an
|
|
796
|
-
* address that is not one, rather than storing a row that can never
|
|
797
|
-
* connect and reports an error every minute forever. */
|
|
798
|
-
addPeer(addr, port) {
|
|
799
|
-
const p = Number(port);
|
|
800
|
-
if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
|
|
801
|
-
const host = addr.trim();
|
|
802
|
-
manual.set(`${host}:${p}`, { fp: `manual:${host}:${p}`, name: host, addr: host, port: p, manual: true });
|
|
803
|
-
return true;
|
|
804
|
-
},
|
|
805
|
-
removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
|
|
806
|
-
/** Replace the typed list wholesale, which is what a settings write means.
|
|
807
|
-
* Adding one at a time would leave a removed address still being dialled
|
|
808
|
-
* every minute until the next restart — the row would vanish from the
|
|
809
|
-
* panel while the socket kept opening, which is the worst of both. */
|
|
810
|
-
setPeers(entries) {
|
|
811
|
-
manual.clear();
|
|
812
|
-
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
813
|
-
const at = String(entry).lastIndexOf(":");
|
|
814
|
-
if (at > 0) this.addPeer(String(entry).slice(0, at), Number(String(entry).slice(at + 1)));
|
|
815
|
-
}
|
|
816
|
-
return manual.size;
|
|
817
|
-
},
|
|
818
|
-
status() {
|
|
819
|
-
return {
|
|
820
|
-
enabled: !!cfg.enabled,
|
|
821
|
-
running: !!beacon,
|
|
822
|
-
// Said only while it is true, and it is only ever true of a deck that
|
|
823
|
-
// is switched on and has no listener.
|
|
824
|
-
stalled: cfg.enabled && !beacon ? stalled : null,
|
|
825
|
-
// When every paired deck was last asked. Null until the first round,
|
|
826
|
-
// which on a deck that has just started is the honest answer.
|
|
827
|
-
checkedAt: roundAt,
|
|
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,
|
|
838
|
-
fp: identity?.fp ?? null,
|
|
839
|
-
// The address and port a person on another subnet types into the other
|
|
840
|
-
// deck's field. Null when this machine has no ordinary one, which the
|
|
841
|
-
// panel says rather than printing a placeholder.
|
|
842
|
-
port: server?.port() ?? null,
|
|
843
|
-
addrs: beacon ? localAddresses() : [],
|
|
844
|
-
shared: [...cfg.shared],
|
|
845
|
-
// The token this deck is offering, if any. Drawn as the one thing to do
|
|
846
|
-
// when nobody is paired yet, and put away once somebody is.
|
|
847
|
-
invite: invite && invite.expiresAt > now()
|
|
848
|
-
? { token: invite.token, expiresAt: invite.expiresAt }
|
|
849
|
-
: null,
|
|
850
|
-
// Decks somebody accepted, decks that asked and have not been answered,
|
|
851
|
-
// and decks merely heard. Three lists because they are three different
|
|
852
|
-
// things a person does something different about.
|
|
853
|
-
trusted: cfg.trusted.map(t => ({ fp: t.fp, name: t.name })),
|
|
854
|
-
pending: [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
|
|
855
|
-
// Only the ones somebody could actually pair with right now, one row
|
|
856
|
-
// per machine, newest first — see pairable, which is where the rule
|
|
857
|
-
// that keeps this from becoming a wall of ghosts lives.
|
|
858
|
-
strangers: (() => {
|
|
859
|
-
// A deck that was told no is not somebody to offer pairing with. It
|
|
860
|
-
// has its own row, with the one control that undoes the decision.
|
|
861
|
-
const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
|
|
862
|
-
// pairable() collapses the rest by machine — see hostId. A computer
|
|
863
|
-
// that has run the deck a few times holds a key per run, and every
|
|
864
|
-
// one of them was a row of its own on everybody else's panel.
|
|
865
|
-
const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
|
|
866
|
-
return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
|
|
867
|
-
})(),
|
|
868
|
-
// Said no to, by somebody at this keyboard. Listed rather than merely
|
|
869
|
-
// silenced, because a refusal nobody can see is a refusal nobody can
|
|
870
|
-
// take back.
|
|
871
|
-
declined: [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
|
|
872
|
-
peers: beacon ? (() => {
|
|
873
|
-
// ONE DECK, ONE ROW, and it takes work because a deck can arrive here
|
|
874
|
-
// twice by two different routes: heard on the network, and dialled at
|
|
875
|
-
// an address somebody typed or that an invite carried. Both are the
|
|
876
|
-
// same machine and neither knows it — the beacon row is keyed by the
|
|
877
|
-
// fingerprint it announced, the typed row by `host:port`, and until a
|
|
878
|
-
// connection succeeds nothing joins them.
|
|
879
|
-
//
|
|
880
|
-
// What joins them is `learned`: the fingerprint that actually
|
|
881
|
-
// answered at that address. So every row is given the identity it is
|
|
882
|
-
// really about, and rows that turn out to share one are merged — the
|
|
883
|
-
// heard half brings liveness, the dialled half brings the last round.
|
|
884
|
-
const rows = [];
|
|
885
|
-
const byId = new Map();
|
|
886
|
-
const put = row => {
|
|
887
|
-
const had = byId.get(row.id);
|
|
888
|
-
if (!had) { byId.set(row.id, row); rows.push(row); return; }
|
|
889
|
-
// Keep what each half is the authority on.
|
|
890
|
-
had.lastSeen = had.lastSeen ?? row.lastSeen;
|
|
891
|
-
had.last = had.last ?? row.last;
|
|
892
|
-
had.manual = had.manual || row.manual;
|
|
893
|
-
had.met = had.met || row.met;
|
|
894
|
-
if (row.name && !had.name) had.name = row.name;
|
|
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
|
-
});
|
|
905
|
-
for (const p of [...beacon.peers.values(), ...manual.values()]) {
|
|
906
|
-
if (!stillListed(p, now())) continue;
|
|
907
|
-
const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
|
|
908
|
-
const id = met?.fp ?? p.fp;
|
|
909
|
-
put({
|
|
910
|
-
...p,
|
|
911
|
-
id,
|
|
912
|
-
// The fingerprint an unpair has to name. A typed row's own `fp` is
|
|
913
|
-
// a placeholder built from its address and matches nothing.
|
|
914
|
-
peerFp: p.manual ? met?.fp ?? null : p.fp,
|
|
915
|
-
name: met?.name || p.name,
|
|
916
|
-
met: !!met,
|
|
917
|
-
paired: !!trustedPeer(cfg.trusted, id),
|
|
918
|
-
last: lastRound.get(p.fp) ?? null,
|
|
919
|
-
...card(id),
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
// A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
|
|
923
|
-
// it, and nothing here has its address — which used to mean the panel
|
|
924
|
-
// listed failing addresses under "paired decks" and left out the one
|
|
925
|
-
// deck that actually was.
|
|
926
|
-
for (const t of cfg.trusted) {
|
|
927
|
-
if (byId.has(t.fp)) continue;
|
|
928
|
-
put({
|
|
929
|
-
id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
|
|
930
|
-
paired: true, waiting: true, last: lastRound.get(t.fp) ?? null,
|
|
931
|
-
// What it is to be "here" for a deck nothing dials: it called,
|
|
932
|
-
// and this is when. Undefined until it has, which is a row the
|
|
933
|
-
// panel draws as unknown rather than as live.
|
|
934
|
-
lastSeen: spokeAt.get(t.fp),
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
return rows;
|
|
938
|
-
})() : [],
|
|
939
|
-
};
|
|
940
|
-
},
|
|
941
|
-
stop() {
|
|
942
|
-
if (timer) clearTimeout(timer);
|
|
943
|
-
// A deliberate stop is not a fault, and the next start says its own.
|
|
944
|
-
if (!cfg.enabled) stalled = null;
|
|
945
|
-
timer = null;
|
|
946
|
-
beacon?.stop();
|
|
947
|
-
server?.stop();
|
|
948
|
-
beacon = null;
|
|
949
|
-
server = null;
|
|
950
|
-
},
|
|
951
|
-
};
|
|
952
|
-
}
|