agent-dag 3.13.0 → 3.15.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.
@@ -0,0 +1,395 @@
1
+ // The sockets. Every decision this makes lives in lan-sync.mjs; what is here is
2
+ // the plumbing that decision layer refuses to own — a UDP socket that shouts,
3
+ // a TCP listener that answers, and the deadlines around both.
4
+ //
5
+ // TWO SOCKETS, AND NEITHER IS THE DECK'S HTTP SERVER. That server binds
6
+ // 127.0.0.1 and stays there. It has a mutation guard that deliberately trusts a
7
+ // request carrying no Origin header, so that hook.js and curl keep working —
8
+ // correct on loopback, and total exposure the moment the same server answers
9
+ // the network. So this feature never asks anybody to run `--host`: it opens its
10
+ // own listener, that listener speaks one protocol and nothing else, and it
11
+ // refuses every frame from anybody who has not proved they hold the group
12
+ // passphrase.
13
+ //
14
+ // DISCOVERY IS BROADCAST ON A FIXED PORT; THE SYNC LISTENER IS EPHEMERAL and
15
+ // says its port in the beacon. One fixed port rather than two is one thing to
16
+ // collide with, one firewall dialog, and one number in a support answer.
17
+ //
18
+ // A DECK HEARS ITSELF. Measured, not assumed: a broadcast to 255.255.255.255
19
+ // comes back to every socket on the sending machine bound to that port, from
20
+ // the machine's own LAN address rather than from loopback. That is what makes
21
+ // two decks on one machine find each other — which is how this gets tested at
22
+ // all — and it is why self-recognition is by fingerprint rather than by
23
+ // address.
24
+ import dgram from "node:dgram";
25
+ import net from "node:net";
26
+ import { randomBytes } from "node:crypto";
27
+ import {
28
+ beaconPayload, beaconVerdict, groupTag, notePeer, proof, proofOk, readBeacon,
29
+ ANNOUNCE_MS, MAX_BEACON_BYTES, MAX_MANIFEST_BYTES,
30
+ } from "./lan-sync.mjs";
31
+
32
+ /** The one fixed port in the feature. Out of the deck's HTTP range (4317-4400)
33
+ * so a beacon can never be mistaken for a deck's own traffic, and unassigned:
34
+ * 45317 reads as "4317, elsewhere", which is what a person tracing this in a
35
+ * firewall log needs it to say. */
36
+ export const DISCOVERY_PORT = 45_317;
37
+
38
+ /** How long a connection has to finish the handshake before it is dropped. A
39
+ * handshake is two round trips on a local network — single-digit
40
+ * milliseconds — so five seconds is generous for a slow machine and short
41
+ * enough that holding sockets open costs an attacker something. */
42
+ export const HANDSHAKE_MS = 5_000;
43
+
44
+ /** The most one frame may be, and the most a peer may hold open.
45
+ *
46
+ * Frames are JSON lines. The largest legitimate one is a manifest; the cap is
47
+ * an order of magnitude over the biggest real store, and the buffer is
48
+ * ABANDONED rather than grown past it — a socket that keeps sending without a
49
+ * newline is trying to make this allocate, and the answer is to stop reading
50
+ * rather than to read faster. */
51
+ export const MAX_FRAME_BYTES = MAX_MANIFEST_BYTES;
52
+
53
+ /** Concurrent connections from all peers together. Small on purpose: the real
54
+ * number is one per peer per minute, and anything above this is either a bug
55
+ * in a peer or somebody holding sockets open to see what happens. */
56
+ export const MAX_SOCKETS = 16;
57
+
58
+ /** The shortest gap between two "I am here too" replies to a stranger. Long
59
+ * enough that a burst of decks starting together cannot make a storm, short
60
+ * enough that starting two decks by hand feels instant. */
61
+ export const REPLY_COOLDOWN_MS = 2_000;
62
+
63
+ /**
64
+ * The shouting half.
65
+ *
66
+ * `reuseAddr` is not a convenience: without it a second deck on the same
67
+ * machine cannot bind the discovery port at all, and two decks on one machine
68
+ * is both a real setup and the only way this gets tested here.
69
+ *
70
+ * Errors are reported, never thrown. A machine with no route, a firewall that
71
+ * refuses the bind, an interface that comes and goes with a VPN — none of them
72
+ * is a reason for the deck to fall over, and all of them are reasons for the
73
+ * panel to be able to say what happened.
74
+ */
75
+ export function createBeacon({
76
+ port, name, fp, key, onPeer, onError, onIdClash, now = Date.now,
77
+ // Injected so the suite can drive this with a socket it controls. CI runners
78
+ // are not a network: GitHub's have no broadcast domain worth the name, and a
79
+ // test that quietly skipped there would be a test that stopped testing
80
+ // without saying so. The REAL socket is exercised by hand, two decks on one
81
+ // machine, which works because a broadcast comes back to its own host.
82
+ createSocket = opts => dgram.createSocket(opts),
83
+ } = {}) {
84
+ const group = key ? groupTag(key) : null;
85
+ // Randomised per process. Two beacons from one fingerprint with different
86
+ // instance ids mean the deck restarted between them, which is the signal to
87
+ // drop any session held for it rather than resume into a process that is gone.
88
+ const instance = randomBytes(8).toString("hex");
89
+ const peers = new Map();
90
+ let sock = null;
91
+ let timer = null;
92
+ /** When this deck last answered a stranger, so answering cannot become a
93
+ * storm. See the reply in the message handler. */
94
+ let repliedAt = 0;
95
+
96
+ const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, group, instance })));
97
+
98
+ const announce = () => {
99
+ if (!sock || !group) return;
100
+ // 255.255.255.255 rather than a multicast group, and rather than the
101
+ // subnet's own broadcast address. The subnet-directed form needs the
102
+ // netmask of whichever interface the packet leaves by, which changes when a
103
+ // VPN comes up; the limited broadcast needs nothing and is what Syncthing
104
+ // sends for the same reason.
105
+ sock.send(payload(), DISCOVERY_PORT, "255.255.255.255", err => {
106
+ // ENETUNREACH and EACCES are what a machine with no network, or a
107
+ // firewall, says. Both are states the panel reports rather than crashes.
108
+ if (err) onError?.("announce", err);
109
+ });
110
+ };
111
+
112
+ const start = () => new Promise(resolve => {
113
+ sock = createSocket({ type: "udp4", reuseAddr: true });
114
+ sock.on("error", err => { onError?.("socket", err); });
115
+ sock.on("message", (msg, rinfo) => {
116
+ // Everything about whether to care lives in lan-sync.mjs. This hands it
117
+ // the bytes and the address and does what it is told.
118
+ if (msg.length > MAX_BEACON_BYTES) return;
119
+ const beacon = readBeacon(msg);
120
+ const verdict = beaconVerdict(beacon, { selfFp: fp, selfGroup: group, selfInstance: instance });
121
+ if (verdict !== "peer") {
122
+ if (verdict === "other-group") onError?.("other-group", null);
123
+ // Another deck is using this one's id — see beaconVerdict. Reported
124
+ // rather than fixed here: this file carries packets, and choosing a new
125
+ // name for the deck belongs to whoever stores it.
126
+ if (verdict === "id-clash") onIdClash?.();
127
+ return;
128
+ }
129
+ const known = peers.has(beacon.fp);
130
+ const noted = notePeer(peers, beacon, rinfo.address, now());
131
+ // ANSWER A DECK WE HAVE NEVER SEEN, once. Without this the second deck to
132
+ // start finds nobody until the first one's next interval — measured on
133
+ // two real decks: 1 saw 2 immediately, 2 saw nobody, because 1's own
134
+ // immediate announce went out before 2 was listening. Thirty seconds of
135
+ // an empty list is how a working feature reads as broken.
136
+ //
137
+ // Only for a stranger, and at most once every few seconds, because the
138
+ // obvious version is a shout storm: two decks answering each other's
139
+ // answers forever. A new pair converges in two extra packets — A answers
140
+ // B, B answers A's answer, and A stays quiet because B is no longer new.
141
+ if (!known && now() - repliedAt > REPLY_COOLDOWN_MS) {
142
+ repliedAt = now();
143
+ announce();
144
+ }
145
+ if (noted.changed || noted.restarted) onPeer?.(noted);
146
+ });
147
+ sock.bind(DISCOVERY_PORT, "0.0.0.0", () => {
148
+ try { sock.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
149
+ // Immediately, not on the next tick. Syncthing's rule: a deck that just
150
+ // came up should appear now rather than up to thirty seconds later, which
151
+ // is the difference between "it works" and "it seems broken" for anybody
152
+ // who starts two decks and watches.
153
+ announce();
154
+ timer = setInterval(announce, ANNOUNCE_MS);
155
+ timer.unref?.();
156
+ resolve();
157
+ });
158
+ });
159
+
160
+ return {
161
+ start,
162
+ announce,
163
+ peers,
164
+ stop() {
165
+ if (timer) clearInterval(timer);
166
+ timer = null;
167
+ try { sock?.close(); } catch { /* already closed */ }
168
+ sock = null;
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Read newline-delimited JSON off a socket, refusing to be made to allocate.
175
+ *
176
+ * The cap is on the UNTERMINATED buffer rather than on a frame that arrived,
177
+ * which is the distinction that matters: a peer that sends a megabyte with no
178
+ * newline in it is not sending a large frame, it is sending nothing at all,
179
+ * expensively. Past the cap this stops reading and hands the caller a refusal
180
+ * — it does not keep buffering in the hope a newline turns up.
181
+ */
182
+ export function frameReader(onFrame, onRefuse, max = MAX_FRAME_BYTES) {
183
+ let buf = "";
184
+ let dead = false;
185
+ return chunk => {
186
+ if (dead) return;
187
+ buf += chunk;
188
+ if (buf.length > max) { dead = true; buf = ""; onRefuse("frame too large"); return; }
189
+ let i;
190
+ while ((i = buf.indexOf("\n")) !== -1) {
191
+ const line = buf.slice(0, i);
192
+ buf = buf.slice(i + 1);
193
+ if (!line.trim()) continue;
194
+ let msg;
195
+ try { msg = JSON.parse(line); }
196
+ catch { dead = true; buf = ""; onRefuse("not json"); return; }
197
+ // `typeof [] === "object"`, so an array walks straight past the obvious
198
+ // check and reaches a handler that reads `msg.t` off it — undefined, and
199
+ // then whatever that handler does with a frame that has no type. A frame
200
+ // is a record; anything else is refused.
201
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
202
+ dead = true; buf = ""; onRefuse("not an object"); return;
203
+ }
204
+ onFrame(msg);
205
+ if (dead) return;
206
+ }
207
+ };
208
+ }
209
+
210
+ /** One line out. Kept in one place so nothing forgets the newline the reader
211
+ * above is waiting for. */
212
+ export function sendFrame(sock, obj) {
213
+ try { sock.write(`${JSON.stringify(obj)}\n`); } catch { /* peer went away */ }
214
+ }
215
+
216
+ /**
217
+ * The answering half.
218
+ *
219
+ * WHAT IT DOES BEFORE IT KNOWS WHO IS CALLING, in order, and the order is the
220
+ * whole security argument: accept, arm a deadline, read at most one frame, and
221
+ * check a proof. No manifest, no account, no store read, nothing that touches
222
+ * claude-swap, until `authed` is true. KDE Connect's CVE-2020-26164 was several
223
+ * issues in a daemon whose protocol was fine, and this is the shape that
224
+ * lesson has.
225
+ *
226
+ * `handlers` is called only with authenticated frames, and it never sees the
227
+ * handshake at all.
228
+ */
229
+ export function createSyncServer({ fp, name, key, handlers, onError, host = "0.0.0.0" } = {}) {
230
+ let server = null;
231
+ const live = new Set();
232
+
233
+ const onConnection = sock => {
234
+ if (!key || live.size >= MAX_SOCKETS) { sock.destroy(); return; }
235
+ live.add(sock);
236
+ sock.setEncoding("utf8");
237
+ sock.setNoDelay(true);
238
+
239
+ let authed = false;
240
+ let peerFp = null;
241
+ const myChallenge = randomBytes(16).toString("hex");
242
+ let theirChallenge = null;
243
+
244
+ // Armed before the first byte is read, and cleared only by a completed
245
+ // handshake. A socket that connects and says nothing is the cheapest
246
+ // possible way to hold a resource, so it is also the first one closed.
247
+ const deadline = setTimeout(() => { if (!authed) sock.destroy(); }, HANDSHAKE_MS);
248
+ deadline.unref?.();
249
+
250
+ const done = () => { clearTimeout(deadline); live.delete(sock); };
251
+ sock.on("close", done);
252
+ sock.on("error", err => { done(); onError?.("peer", err); });
253
+
254
+ const refuse = why => { onError?.("frame", new Error(why)); sock.destroy(); };
255
+
256
+ sock.on("data", frameReader(msg => {
257
+ if (!authed) {
258
+ // FOUR MESSAGES, and the order is chosen so that a stranger who merely
259
+ // connects receives nothing derived from the group key.
260
+ //
261
+ // 1. caller -> hello, its fingerprint and a fresh challenge
262
+ // 2. us -> challenge, a random number and nothing else
263
+ // 3. caller -> auth, a proof over both challenges
264
+ // 4. us -> ok, our name, and our proof over both
265
+ //
266
+ // The tempting three-message version has us answer the hello with our
267
+ // proof already in it. That hands anybody who opens a socket a MAC over
268
+ // values they chose, forever, for free. Here they get a random number:
269
+ // to obtain any keyed material they have to produce a valid proof
270
+ // first, which is the thing they do not have.
271
+ if (msg.t === "hello") {
272
+ if (theirChallenge || typeof msg.fp !== "string" || typeof msg.challenge !== "string") {
273
+ return refuse("bad hello");
274
+ }
275
+ theirChallenge = msg.challenge;
276
+ peerFp = msg.fp;
277
+ // Our fingerprint travels with the challenge. It is already in
278
+ // every beacon we broadcast, so this tells an unauthenticated caller
279
+ // nothing new — and without it the caller cannot name us in its
280
+ // proof, which would leave the transcript covering only one of the
281
+ // two decks and one recorded proof valid at every deck in the group.
282
+ sendFrame(sock, { t: "challenge", fp, challenge: myChallenge });
283
+ return;
284
+ }
285
+ if (msg.t !== "auth" || !theirChallenge) return refuse("expected auth");
286
+ const want = proof(key, {
287
+ challenge: theirChallenge, peerChallenge: myChallenge,
288
+ fromFp: peerFp, toFp: fp, direction: "hello",
289
+ });
290
+ // A recording of a previous exchange fails here, because `myChallenge`
291
+ // was made when this socket opened and has never been sent before.
292
+ if (!proofOk(want, msg.proof)) return refuse("bad proof");
293
+ authed = true;
294
+ clearTimeout(deadline);
295
+ // And ours, so the caller knows it reached a deck in its own group
296
+ // rather than something standing in the way of one.
297
+ sendFrame(sock, {
298
+ t: "ok", fp, name,
299
+ proof: proof(key, {
300
+ challenge: myChallenge, peerChallenge: theirChallenge,
301
+ fromFp: fp, toFp: peerFp, direction: "reply",
302
+ }),
303
+ });
304
+ return;
305
+ }
306
+ handlers?.(msg, { sock, peerFp, send: obj => sendFrame(sock, obj) });
307
+ }, refuse));
308
+ };
309
+
310
+ return {
311
+ start: () => new Promise((resolve, reject) => {
312
+ server = net.createServer(onConnection);
313
+ server.on("error", err => { onError?.("listen", err); reject(err); });
314
+ // Port 0: the OS picks, and the beacon carries whichever it picked. One
315
+ // fixed port in this feature is enough to collide with.
316
+ server.listen(0, host, () => resolve(server.address().port));
317
+ }),
318
+ port: () => server?.address()?.port ?? null,
319
+ stop() {
320
+ for (const s of live) s.destroy();
321
+ live.clear();
322
+ try { server?.close(); } catch { /* not listening */ }
323
+ server = null;
324
+ },
325
+ };
326
+ }
327
+
328
+ /**
329
+ * The calling half: connect, prove, be proved to, then talk.
330
+ *
331
+ * BOTH SIDES PROVE, and the second half is the one that is easy to skip. A
332
+ * handshake where only the caller proves itself stops a stranger reading a
333
+ * manifest and stops nothing else — a stranger can still stand up a listener on
334
+ * the announced port, wait for a real deck to dial it, and be handed whatever
335
+ * that deck was going to say. So this checks the reply with the same care the
336
+ * server checks the hello, and gives up if it does not hold.
337
+ */
338
+ export function connectToPeer({ host, port, fp, key, timeoutMs = HANDSHAKE_MS }) {
339
+ return new Promise((resolve, reject) => {
340
+ const myChallenge = randomBytes(16).toString("hex");
341
+ const sock = net.createConnection({ host, port });
342
+ sock.setEncoding("utf8");
343
+ let settled = false;
344
+ const fail = err => {
345
+ if (settled) return;
346
+ settled = true;
347
+ sock.destroy();
348
+ reject(err instanceof Error ? err : new Error(String(err)));
349
+ };
350
+ const timer = setTimeout(() => fail(new Error("handshake timed out")), timeoutMs);
351
+ timer.unref?.();
352
+
353
+ sock.on("error", fail);
354
+ sock.on("close", () => fail(new Error("peer closed the connection")));
355
+ sock.on("connect", () => {
356
+ // No proof in the hello: the caller cannot cover a challenge it has not
357
+ // been given, and a proof over an empty one would be a proof that means
358
+ // nothing. It goes in message three.
359
+ sendFrame(sock, { t: "hello", fp, challenge: myChallenge });
360
+ });
361
+
362
+ let theirChallenge = null;
363
+ let theirFp = null;
364
+ sock.on("data", frameReader(msg => {
365
+ if (settled) return;
366
+ if (msg.t === "challenge") {
367
+ if (theirFp || typeof msg.challenge !== "string" || typeof msg.fp !== "string") {
368
+ return fail(new Error("bad challenge"));
369
+ }
370
+ theirChallenge = msg.challenge;
371
+ theirFp = msg.fp;
372
+ sendFrame(sock, {
373
+ t: "auth",
374
+ proof: proof(key, {
375
+ challenge: myChallenge, peerChallenge: theirChallenge,
376
+ fromFp: fp, toFp: theirFp, direction: "hello",
377
+ }),
378
+ });
379
+ return;
380
+ }
381
+ // The same deck that gave us the challenge, or nothing: a reply naming a
382
+ // different fingerprint is a second party in the middle of this.
383
+ if (msg.t !== "ok" || msg.fp !== theirFp) return fail(new Error("expected ok"));
384
+ const want = proof(key, {
385
+ challenge: theirChallenge, peerChallenge: myChallenge,
386
+ fromFp: theirFp, toFp: fp, direction: "reply",
387
+ });
388
+ if (!proofOk(want, msg.proof)) return fail(new Error("peer could not prove the group"));
389
+ settled = true;
390
+ clearTimeout(timer);
391
+ sock.removeAllListeners("close");
392
+ resolve({ sock, peerFp: msg.fp, peerName: msg.name, send: obj => sendFrame(sock, obj) });
393
+ }, fail));
394
+ });
395
+ }