agent-dag 3.22.1 → 3.22.4
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-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- 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,682 +0,0 @@
|
|
|
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 { networkInterfaces } from "node:os";
|
|
26
|
-
|
|
27
|
-
/** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
|
|
28
|
-
function quad(text) {
|
|
29
|
-
const parts = String(text ?? "").split(".");
|
|
30
|
-
if (parts.length !== 4) return null;
|
|
31
|
-
const out = parts.map(p => Number(p));
|
|
32
|
-
return out.every(n => Number.isInteger(n) && n >= 0 && n <= 255) ? out : null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The address that reaches every host on one interface's own subnet.
|
|
37
|
-
*
|
|
38
|
-
* `address | ~netmask`, which is the definition. Null for a /32, because a
|
|
39
|
-
* point-to-point link — a VPN tunnel, `utun` on macOS — has a directed
|
|
40
|
-
* broadcast equal to its own address, and sending a beacon to ourselves down a
|
|
41
|
-
* tunnel is a packet nobody wanted.
|
|
42
|
-
*/
|
|
43
|
-
export function directedBroadcast(address, netmask) {
|
|
44
|
-
const a = quad(address);
|
|
45
|
-
const m = quad(netmask);
|
|
46
|
-
if (!a || !m) return null;
|
|
47
|
-
if (m.every(o => o === 255)) return null;
|
|
48
|
-
return a.map((o, i) => o | (~m[i] & 255)).join(".");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Where a beacon has to go to be heard on this machine's networks.
|
|
53
|
-
*
|
|
54
|
-
* The limited broadcast first, because it is the one that works where a router
|
|
55
|
-
* or an access point filters the directed form, and because it is what every
|
|
56
|
-
* deck before this version sent — a machine that was fine stays fine. Then one
|
|
57
|
-
* per interface, which is what a multi-homed host actually needs: see announce.
|
|
58
|
-
*
|
|
59
|
-
* Loopback and IPv6 are skipped. A deck on `lo0` can only hear itself, and this
|
|
60
|
-
* protocol is IPv4 broadcast by construction — there is no such thing as an
|
|
61
|
-
* IPv6 broadcast address.
|
|
62
|
-
*/
|
|
63
|
-
export function broadcastTargets(ifaces) {
|
|
64
|
-
const out = ["255.255.255.255"];
|
|
65
|
-
for (const list of Object.values(ifaces ?? {})) {
|
|
66
|
-
for (const ni of list ?? []) {
|
|
67
|
-
if (!ni || ni.internal) continue;
|
|
68
|
-
// Node 18 reports `family` as the string "IPv4"; older shapes used 4.
|
|
69
|
-
if (ni.family !== "IPv4" && ni.family !== 4) continue;
|
|
70
|
-
const to = directedBroadcast(ni.address, ni.netmask);
|
|
71
|
-
if (to && !out.includes(to)) out.push(to);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return out;
|
|
75
|
-
}
|
|
76
|
-
import net from "node:net";
|
|
77
|
-
import { randomBytes } from "node:crypto";
|
|
78
|
-
import {
|
|
79
|
-
beaconPayload, beaconVerdict, handshakeTranscript, hostId, notePeer, proof, proofOk,
|
|
80
|
-
inviteProof, readBeacon, readPub, sessionKey, trustedPeer,
|
|
81
|
-
ANNOUNCE_MS, MAX_BEACON_BYTES, MAX_MANIFEST_BYTES,
|
|
82
|
-
} from "./lan-sync.mjs";
|
|
83
|
-
|
|
84
|
-
/** The one fixed port in the feature. Out of the deck's HTTP range (4317-4400)
|
|
85
|
-
* so a beacon can never be mistaken for a deck's own traffic, and unassigned:
|
|
86
|
-
* 45317 reads as "4317, elsewhere", which is what a person tracing this in a
|
|
87
|
-
* firewall log needs it to say. */
|
|
88
|
-
export const DISCOVERY_PORT = 45_317;
|
|
89
|
-
|
|
90
|
-
/** How long a connection has to finish the handshake before it is dropped. A
|
|
91
|
-
* handshake is two round trips on a local network — single-digit
|
|
92
|
-
* milliseconds — so five seconds is generous for a slow machine and short
|
|
93
|
-
* enough that holding sockets open costs an attacker something. */
|
|
94
|
-
export const HANDSHAKE_MS = 5_000;
|
|
95
|
-
|
|
96
|
-
/** The most one frame may be, and the most a peer may hold open.
|
|
97
|
-
*
|
|
98
|
-
* Frames are JSON lines. The largest legitimate one is a manifest; the cap is
|
|
99
|
-
* an order of magnitude over the biggest real store, and the buffer is
|
|
100
|
-
* ABANDONED rather than grown past it — a socket that keeps sending without a
|
|
101
|
-
* newline is trying to make this allocate, and the answer is to stop reading
|
|
102
|
-
* rather than to read faster. */
|
|
103
|
-
export const MAX_FRAME_BYTES = MAX_MANIFEST_BYTES;
|
|
104
|
-
|
|
105
|
-
/** Concurrent connections from all peers together. Small on purpose: the real
|
|
106
|
-
* number is one per peer per minute, and anything above this is either a bug
|
|
107
|
-
* in a peer or somebody holding sockets open to see what happens. */
|
|
108
|
-
export const MAX_SOCKETS = 16;
|
|
109
|
-
|
|
110
|
-
/** The shortest gap between two "I am here too" replies to a stranger. Long
|
|
111
|
-
* enough that a burst of decks starting together cannot make a storm, short
|
|
112
|
-
* enough that starting two decks by hand feels instant. */
|
|
113
|
-
export const REPLY_COOLDOWN_MS = 2_000;
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The shouting half.
|
|
117
|
-
*
|
|
118
|
-
* `reuseAddr` is not a convenience: without it a second deck on the same
|
|
119
|
-
* machine cannot bind the discovery port at all, and two decks on one machine
|
|
120
|
-
* is both a real setup and the only way this gets tested here.
|
|
121
|
-
*
|
|
122
|
-
* Errors are reported, never thrown. A machine with no route, a firewall that
|
|
123
|
-
* refuses the bind, an interface that comes and goes with a VPN — none of them
|
|
124
|
-
* is a reason for the deck to fall over, and all of them are reasons for the
|
|
125
|
-
* panel to be able to say what happened.
|
|
126
|
-
*/
|
|
127
|
-
export function createBeacon({
|
|
128
|
-
port, name, fp, onPeer, onStranger, onError, onIdClash, now = Date.now,
|
|
129
|
-
/** The decks somebody has accepted, read fresh each packet so an accept takes
|
|
130
|
-
* effect immediately rather than at the next restart. */
|
|
131
|
-
trusted = () => [],
|
|
132
|
-
// Injected so the suite can drive this with a socket it controls. CI runners
|
|
133
|
-
// are not a network: GitHub's have no broadcast domain worth the name, and a
|
|
134
|
-
// test that quietly skipped there would be a test that stopped testing
|
|
135
|
-
// without saying so. The REAL socket is exercised by hand, two decks on one
|
|
136
|
-
// machine, which works because a broadcast comes back to its own host.
|
|
137
|
-
createSocket = opts => dgram.createSocket(opts),
|
|
138
|
-
// Injected for the same reason, and read PER ANNOUNCE rather than once: a
|
|
139
|
-
// laptop that joins a network, or brings a VPN up, grows an interface without
|
|
140
|
-
// restarting the deck, and a list captured at start would announce to the
|
|
141
|
-
// addresses it had at breakfast.
|
|
142
|
-
ifaces = () => networkInterfaces(),
|
|
143
|
-
} = {}) {
|
|
144
|
-
// Randomised per process. Two beacons from one fingerprint with different
|
|
145
|
-
// instance ids mean the deck restarted between them, which is the signal to
|
|
146
|
-
// drop any session held for it rather than resume into a process that is gone.
|
|
147
|
-
const instance = randomBytes(8).toString("hex");
|
|
148
|
-
/** Which computer this is, as opposed to which process or which key. Derived
|
|
149
|
-
* once per beacon rather than per packet: it cannot change while a process
|
|
150
|
-
* is running, and hashing a hostname thirty seconds apart forever is work
|
|
151
|
-
* nobody asked for. See hostId. */
|
|
152
|
-
const host = hostId();
|
|
153
|
-
const peers = new Map();
|
|
154
|
-
let sock = null;
|
|
155
|
-
let timer = null;
|
|
156
|
-
/** When this deck last answered a deck it had not heard, so answering cannot
|
|
157
|
-
* become a storm, and which decks it has already answered — without the
|
|
158
|
-
* second, a deck that is never accepted is answered again on every packet
|
|
159
|
-
* for as long as both are running. */
|
|
160
|
-
let repliedAt = 0;
|
|
161
|
-
const answered = new Set();
|
|
162
|
-
|
|
163
|
-
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
|
|
164
|
-
|
|
165
|
-
const announce = () => {
|
|
166
|
-
if (!sock) return;
|
|
167
|
-
// EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
|
|
168
|
-
//
|
|
169
|
-
// This used to send only to 255.255.255.255, on the argument that the
|
|
170
|
-
// limited broadcast "needs nothing" while a subnet-directed one needs the
|
|
171
|
-
// netmask of whichever interface the packet leaves by. The argument is
|
|
172
|
-
// sound and the machine disagreed with it. Measured on a Mac with Wi-Fi and
|
|
173
|
-
// a VPN tunnel up, from a bare node process with no deck involved:
|
|
174
|
-
//
|
|
175
|
-
// send to 255.255.255.255 -> EHOSTUNREACH
|
|
176
|
-
// send to 192.168.1.255 -> sent ok
|
|
177
|
-
//
|
|
178
|
-
// On a multi-homed host the limited broadcast has no single interface to
|
|
179
|
-
// leave by, and macOS refuses it rather than choosing. The deck went on
|
|
180
|
-
// announcing into nothing for as long as that machine was up: it could still
|
|
181
|
-
// HEAR colleagues, because receiving is per-port and not per-address, so the
|
|
182
|
-
// symptom was one-sided and looked like everybody else's problem.
|
|
183
|
-
//
|
|
184
|
-
// So the limited form stays — it is the one that works where a directed
|
|
185
|
-
// broadcast is filtered, and it is what Syncthing sends — and every
|
|
186
|
-
// interface's own directed broadcast goes out beside it. A duplicate packet
|
|
187
|
-
// costs one datagram; a missing one costs the whole feature.
|
|
188
|
-
const targets = broadcastTargets(ifaces());
|
|
189
|
-
let left = targets.length;
|
|
190
|
-
const failed = [];
|
|
191
|
-
for (const to of targets) {
|
|
192
|
-
sock.send(payload(), DISCOVERY_PORT, to, err => {
|
|
193
|
-
if (err) failed.push(`${to} (${err.code ?? err.message})`);
|
|
194
|
-
// REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
|
|
195
|
-
// the ordinary state of a machine with a VPN up, and a panel that said
|
|
196
|
-
// so every thirty seconds would be crying wolf about a working deck.
|
|
197
|
-
// No address working at all is a deck nobody can discover, which is
|
|
198
|
-
// exactly what the panel is for.
|
|
199
|
-
if (--left === 0 && failed.length === targets.length) {
|
|
200
|
-
onError?.("announce", new Error(`no broadcast address worked — ${failed.join(", ")}`));
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const start = () => new Promise(resolve => {
|
|
207
|
-
sock = createSocket({ type: "udp4", reuseAddr: true });
|
|
208
|
-
sock.on("error", err => { onError?.("socket", err); });
|
|
209
|
-
sock.on("message", (msg, rinfo) => {
|
|
210
|
-
// Everything about whether to care lives in lan-sync.mjs. This hands it
|
|
211
|
-
// the bytes and the address and does what it is told.
|
|
212
|
-
if (msg.length > MAX_BEACON_BYTES) return;
|
|
213
|
-
const beacon = readBeacon(msg);
|
|
214
|
-
const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
|
|
215
|
-
// ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
|
|
216
|
-
// part is the change. It used to answer only a deck already in the group,
|
|
217
|
-
// which was fine when a group existed. Now the first thing a new deck has
|
|
218
|
-
// to become is a row on somebody's screen, and it cannot become one if
|
|
219
|
-
// this deck never tells it that it exists.
|
|
220
|
-
//
|
|
221
|
-
// Measured on two real decks before any of this: deck 1 saw deck 2 the
|
|
222
|
-
// instant it started and deck 2 saw nobody, because deck 1's own
|
|
223
|
-
// immediate announce went out before deck 2 was listening. Thirty seconds
|
|
224
|
-
// of an empty list is how a working feature reads as broken.
|
|
225
|
-
//
|
|
226
|
-
// At most once every few seconds, because the obvious version is a shout
|
|
227
|
-
// storm: two decks answering each other's answers forever. A new pair
|
|
228
|
-
// converges in two extra packets.
|
|
229
|
-
const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
|
|
230
|
-
&& !peers.has(beacon.fp) && !answered.has(beacon.fp);
|
|
231
|
-
if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
|
|
232
|
-
repliedAt = now();
|
|
233
|
-
answered.add(beacon.fp);
|
|
234
|
-
announce();
|
|
235
|
-
}
|
|
236
|
-
if (verdict !== "peer") {
|
|
237
|
-
// Another deck is using this one's key — see beaconVerdict. Reported
|
|
238
|
-
// rather than fixed here: this file carries packets, and choosing a new
|
|
239
|
-
// identity for the deck belongs to whoever stores it.
|
|
240
|
-
if (verdict === "id-clash") onIdClash?.();
|
|
241
|
-
// A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
|
|
242
|
-
// dropped: it is a name and an address on the same network, which is a
|
|
243
|
-
// row somebody can accept. Nothing is asked of it and nothing is
|
|
244
|
-
// offered to it until they do.
|
|
245
|
-
if (verdict === "stranger") {
|
|
246
|
-
onStranger?.({
|
|
247
|
-
fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
|
|
248
|
-
// Carried through so the list can show one row per machine rather
|
|
249
|
-
// than one per key that machine has ever held.
|
|
250
|
-
host: beacon.host, at: now(),
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
const noted = notePeer(peers, beacon, rinfo.address, now());
|
|
256
|
-
if (noted.changed || noted.restarted) onPeer?.(noted);
|
|
257
|
-
});
|
|
258
|
-
sock.bind(DISCOVERY_PORT, "0.0.0.0", () => {
|
|
259
|
-
try { sock.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
|
|
260
|
-
// Immediately, not on the next tick. Syncthing's rule: a deck that just
|
|
261
|
-
// came up should appear now rather than up to thirty seconds later, which
|
|
262
|
-
// is the difference between "it works" and "it seems broken" for anybody
|
|
263
|
-
// who starts two decks and watches.
|
|
264
|
-
announce();
|
|
265
|
-
timer = setInterval(announce, ANNOUNCE_MS);
|
|
266
|
-
timer.unref?.();
|
|
267
|
-
resolve();
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
start,
|
|
273
|
-
announce,
|
|
274
|
-
peers,
|
|
275
|
-
stop() {
|
|
276
|
-
if (timer) clearInterval(timer);
|
|
277
|
-
timer = null;
|
|
278
|
-
try { sock?.close(); } catch { /* already closed */ }
|
|
279
|
-
sock = null;
|
|
280
|
-
},
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Read newline-delimited JSON off a socket, refusing to be made to allocate.
|
|
286
|
-
*
|
|
287
|
-
* The cap is on the UNTERMINATED buffer rather than on a frame that arrived,
|
|
288
|
-
* which is the distinction that matters: a peer that sends a megabyte with no
|
|
289
|
-
* newline in it is not sending a large frame, it is sending nothing at all,
|
|
290
|
-
* expensively. Past the cap this stops reading and hands the caller a refusal
|
|
291
|
-
* — it does not keep buffering in the hope a newline turns up.
|
|
292
|
-
*/
|
|
293
|
-
export function frameReader(onFrame, onRefuse, max = MAX_FRAME_BYTES) {
|
|
294
|
-
let buf = "";
|
|
295
|
-
let dead = false;
|
|
296
|
-
return chunk => {
|
|
297
|
-
if (dead) return;
|
|
298
|
-
buf += chunk;
|
|
299
|
-
if (buf.length > max) { dead = true; buf = ""; onRefuse("frame too large"); return; }
|
|
300
|
-
let i;
|
|
301
|
-
while ((i = buf.indexOf("\n")) !== -1) {
|
|
302
|
-
const line = buf.slice(0, i);
|
|
303
|
-
buf = buf.slice(i + 1);
|
|
304
|
-
if (!line.trim()) continue;
|
|
305
|
-
let msg;
|
|
306
|
-
try { msg = JSON.parse(line); }
|
|
307
|
-
catch { dead = true; buf = ""; onRefuse("not json"); return; }
|
|
308
|
-
// `typeof [] === "object"`, so an array walks straight past the obvious
|
|
309
|
-
// check and reaches a handler that reads `msg.t` off it — undefined, and
|
|
310
|
-
// then whatever that handler does with a frame that has no type. A frame
|
|
311
|
-
// is a record; anything else is refused.
|
|
312
|
-
if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
|
|
313
|
-
dead = true; buf = ""; onRefuse("not an object"); return;
|
|
314
|
-
}
|
|
315
|
-
onFrame(msg);
|
|
316
|
-
if (dead) return;
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** One line out. Kept in one place so nothing forgets the newline the reader
|
|
322
|
-
* above is waiting for. */
|
|
323
|
-
export function sendFrame(sock, obj) {
|
|
324
|
-
try { sock.write(`${JSON.stringify(obj)}\n`); } catch { /* peer went away */ }
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* The answering half.
|
|
329
|
-
*
|
|
330
|
-
* WHAT IT DOES BEFORE IT KNOWS WHO IS CALLING, in order, and the order is the
|
|
331
|
-
* whole security argument: accept, arm a deadline, read at most one frame, and
|
|
332
|
-
* check a proof. No manifest, no account, no store read, nothing that touches
|
|
333
|
-
* claude-swap, until `authed` is true. KDE Connect's CVE-2020-26164 was several
|
|
334
|
-
* issues in a daemon whose protocol was fine, and this is the shape that
|
|
335
|
-
* lesson has.
|
|
336
|
-
*
|
|
337
|
-
* `handlers` is called only with authenticated frames, and it never sees the
|
|
338
|
-
* handshake at all.
|
|
339
|
-
*/
|
|
340
|
-
export function createSyncServer({
|
|
341
|
-
fp, pub, name, secret, handlers, onError, host = "0.0.0.0", prefer = 0,
|
|
342
|
-
/** The peers somebody has accepted, read fresh on every connection so an
|
|
343
|
-
* accept takes effect on the next one rather than on the next restart. */
|
|
344
|
-
trusted = () => [],
|
|
345
|
-
/** A deck we have never been told to trust, which finished the handshake and
|
|
346
|
-
* is therefore a real deck rather than a port scan. The panel turns this
|
|
347
|
-
* into a row with an accept on it. */
|
|
348
|
-
onPending,
|
|
349
|
-
/** Was this deck already told no? A refusal has to be sent rather than
|
|
350
|
-
* merely held, or the far end reads "declined" and "not answered yet" as one
|
|
351
|
-
* silent wait — they are the same frame otherwise, and only one of them ever
|
|
352
|
-
* comes right by waiting. */
|
|
353
|
-
declined = () => false,
|
|
354
|
-
/** The invite this deck is currently offering, or null. A caller that proves
|
|
355
|
-
* it holds the code is somebody the owner handed a token to, so it is paired
|
|
356
|
-
* on arrival rather than queued behind a press. */
|
|
357
|
-
invite = () => null,
|
|
358
|
-
/** One was used. The caller stores the pairing and retires the invite: a
|
|
359
|
-
* token that pairs twice is a token worth stealing twice. */
|
|
360
|
-
onInviteUsed,
|
|
361
|
-
} = {}) {
|
|
362
|
-
let server = null;
|
|
363
|
-
const live = new Set();
|
|
364
|
-
|
|
365
|
-
const onConnection = sock => {
|
|
366
|
-
if (!secret || live.size >= MAX_SOCKETS) { sock.destroy(); return; }
|
|
367
|
-
live.add(sock);
|
|
368
|
-
sock.setEncoding("utf8");
|
|
369
|
-
sock.setNoDelay(true);
|
|
370
|
-
|
|
371
|
-
let authed = false;
|
|
372
|
-
let peerFp = null;
|
|
373
|
-
let peerPub = null;
|
|
374
|
-
let peerName = "";
|
|
375
|
-
let peerPort = null;
|
|
376
|
-
let key = null;
|
|
377
|
-
const myChallenge = randomBytes(16).toString("hex");
|
|
378
|
-
let theirChallenge = null;
|
|
379
|
-
|
|
380
|
-
// Armed before the first byte is read, and cleared only by a completed
|
|
381
|
-
// handshake. A socket that connects and says nothing is the cheapest
|
|
382
|
-
// possible way to hold a resource, so it is also the first one closed.
|
|
383
|
-
const deadline = setTimeout(() => { if (!authed) sock.destroy(); }, HANDSHAKE_MS);
|
|
384
|
-
deadline.unref?.();
|
|
385
|
-
|
|
386
|
-
const done = () => { clearTimeout(deadline); live.delete(sock); };
|
|
387
|
-
sock.on("close", done);
|
|
388
|
-
sock.on("error", err => { done(); onError?.("peer", err); });
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Refuse, and SAY SO, which cost two people twenty minutes.
|
|
392
|
-
*
|
|
393
|
-
* This used to destroy the socket without a word, so the caller's only
|
|
394
|
-
* evidence was `peer closed the connection` — true, and useless. Every
|
|
395
|
-
* reason here is a different problem with a different fix, and the caller
|
|
396
|
-
* cannot tell them apart from the outside.
|
|
397
|
-
*
|
|
398
|
-
* It leaks nothing an attacker did not have. "I do not know you" is what
|
|
399
|
-
* the silent close already said, and the fingerprints involved are in every
|
|
400
|
-
* beacon this deck broadcasts.
|
|
401
|
-
*
|
|
402
|
-
* DESTROY, NOT END, and the difference is a caller that never reads. `end`
|
|
403
|
-
* is a FIN, and a peer whose socket is paused — connected, refusing to
|
|
404
|
-
* read, which is exactly the shape of a caller trying to cost something —
|
|
405
|
-
* never notices a FIN and holds the socket open. The callback orders the
|
|
406
|
-
* two: destroying before the write flushes would throw away the sentence
|
|
407
|
-
* that is the whole point. The timer is the backstop for a peer whose
|
|
408
|
-
* receive window is full and whose callback therefore never comes.
|
|
409
|
-
*/
|
|
410
|
-
const refuse = why => {
|
|
411
|
-
onError?.("frame", new Error(why));
|
|
412
|
-
const bye = () => { try { sock.destroy(); } catch { /* already gone */ } };
|
|
413
|
-
try { sock.write(`${JSON.stringify({ t: "no", why })}\n`, bye); }
|
|
414
|
-
catch { bye(); return; }
|
|
415
|
-
setTimeout(bye, 250).unref?.();
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
sock.on("data", frameReader(msg => {
|
|
419
|
-
if (!authed) {
|
|
420
|
-
// FOUR MESSAGES, and the order is chosen so that a stranger who merely
|
|
421
|
-
// connects receives nothing derived from a key.
|
|
422
|
-
//
|
|
423
|
-
// 1. caller -> hello, its fingerprint, its public key, a challenge
|
|
424
|
-
// 2. us -> challenge, ours, and a random number
|
|
425
|
-
// 3. caller -> auth, a proof over the whole transcript
|
|
426
|
-
// 4. us -> ok, our name, and our proof over the same
|
|
427
|
-
//
|
|
428
|
-
// BOTH PUBLIC KEYS TRAVEL IN THE CLEAR and that is fine: a public key
|
|
429
|
-
// is public, and the fingerprint in the beacon is a hash of this exact
|
|
430
|
-
// value. What the exchange establishes is that whoever is on the other
|
|
431
|
-
// end holds the private half of the key they claimed — which is the
|
|
432
|
-
// only thing a pin can later be checked against.
|
|
433
|
-
if (msg.t === "hello") {
|
|
434
|
-
if (theirChallenge || typeof msg.challenge !== "string") return refuse("bad hello");
|
|
435
|
-
const them = readPub(msg.pub);
|
|
436
|
-
// The fingerprint is a hash of the key, so a hello whose two halves
|
|
437
|
-
// disagree is not a deck with a stale field, it is somebody trying to
|
|
438
|
-
// be announced as one deck and prove they are another.
|
|
439
|
-
if (!them || them.fp !== msg.fp) return refuse("bad hello");
|
|
440
|
-
theirChallenge = msg.challenge;
|
|
441
|
-
peerFp = them.fp;
|
|
442
|
-
peerPub = them.pub;
|
|
443
|
-
peerName = typeof msg.name === "string" ? msg.name : "";
|
|
444
|
-
peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
|
|
445
|
-
key = sessionKey(secret, peerPub, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
|
|
446
|
-
sendFrame(sock, { t: "challenge", fp, pub, name, challenge: myChallenge });
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
if (msg.t !== "auth" || !theirChallenge) return refuse("expected auth");
|
|
450
|
-
const want = proof(key, {
|
|
451
|
-
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
452
|
-
fromFp: peerFp, toFp: fp, direction: "hello",
|
|
453
|
-
});
|
|
454
|
-
// A recording of a previous exchange fails here, because `myChallenge`
|
|
455
|
-
// was made when this socket opened and has never been sent before.
|
|
456
|
-
if (!proofOk(want, msg.proof)) return refuse("bad proof");
|
|
457
|
-
|
|
458
|
-
// WHO IS THIS, and it is the only question left. The handshake proves
|
|
459
|
-
// they hold the key they claimed; the trusted list says whether anybody
|
|
460
|
-
// here ever agreed to talk to it.
|
|
461
|
-
const known = trustedPeer(trusted(), peerFp);
|
|
462
|
-
if (known && known.pub !== peerPub) {
|
|
463
|
-
// The fingerprint we pinned, presented with a different key. 48 bits
|
|
464
|
-
// is far past accident, so this is somebody wearing a paired deck's
|
|
465
|
-
// name — refused loudly rather than quietly re-pinned.
|
|
466
|
-
return refuse("impostor");
|
|
467
|
-
}
|
|
468
|
-
if (!known) {
|
|
469
|
-
// AN INVITE THIS DECK HANDED OUT, PRESENTED BACK. Whoever is calling
|
|
470
|
-
// holds a token the owner of this machine copied and sent, which is
|
|
471
|
-
// the same decision the accept button is — made earlier, and made
|
|
472
|
-
// once. So there is nothing to press: the deck is pinned here.
|
|
473
|
-
//
|
|
474
|
-
// The proof is over the transcript, so it is worth nothing to
|
|
475
|
-
// somebody who recorded an earlier exchange, and the code itself
|
|
476
|
-
// never travels.
|
|
477
|
-
const live = invite();
|
|
478
|
-
if (live && typeof msg.invite === "string") {
|
|
479
|
-
const want = inviteProof(live.code, handshakeTranscript(peerFp, fp, theirChallenge, myChallenge));
|
|
480
|
-
if (proofOk(want, msg.invite)) {
|
|
481
|
-
onInviteUsed?.({ fp: peerFp, pub: peerPub, name: peerName, port: peerPort,
|
|
482
|
-
addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "" });
|
|
483
|
-
authed = true;
|
|
484
|
-
clearTimeout(deadline);
|
|
485
|
-
sendFrame(sock, {
|
|
486
|
-
t: "ok", fp, name,
|
|
487
|
-
proof: proof(key, {
|
|
488
|
-
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
489
|
-
fromFp: fp, toFp: peerFp, direction: "reply",
|
|
490
|
-
}),
|
|
491
|
-
});
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
// A DECK THIS ONE'S OWNER ALREADY ANSWERED, and the answer was no.
|
|
496
|
-
// It is not asked again here, and — the half a held refusal cannot
|
|
497
|
-
// do — the deck that asked is told, so its own panel can stop saying
|
|
498
|
-
// "waiting" about a question that has been answered.
|
|
499
|
-
if (declined(peerFp)) return refuse("declined");
|
|
500
|
-
|
|
501
|
-
// A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
|
|
502
|
-
// a port scan, and it told us a name and an address a person can
|
|
503
|
-
// recognise. That is a row with an accept on it, and nothing else
|
|
504
|
-
// happens until somebody presses it.
|
|
505
|
-
onPending?.({
|
|
506
|
-
fp: peerFp, pub: peerPub, name: peerName,
|
|
507
|
-
addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "",
|
|
508
|
-
// Where it LISTENS, from the hello — not this socket's remote port,
|
|
509
|
-
// which is ephemeral. This is what lets an accept dial back.
|
|
510
|
-
port: peerPort,
|
|
511
|
-
});
|
|
512
|
-
return refuse("pending");
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
authed = true;
|
|
516
|
-
clearTimeout(deadline);
|
|
517
|
-
// And ours, so the caller knows it reached the deck it pinned rather
|
|
518
|
-
// than something standing in the way of one.
|
|
519
|
-
sendFrame(sock, {
|
|
520
|
-
t: "ok", fp, name,
|
|
521
|
-
proof: proof(key, {
|
|
522
|
-
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
523
|
-
fromFp: fp, toFp: peerFp, direction: "reply",
|
|
524
|
-
}),
|
|
525
|
-
});
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
handlers?.(msg, { sock, peerFp, key, send: obj => sendFrame(sock, obj) });
|
|
529
|
-
}, refuse));
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
return {
|
|
533
|
-
/**
|
|
534
|
-
* Listen, on the same port as last time when that is still possible.
|
|
535
|
-
*
|
|
536
|
-
* IT ASKED FOR PORT 0 EVERY TIME, and the reasoning was sound in isolation:
|
|
537
|
-
* the beacon carries whichever port the OS picked, so nothing needs a fixed
|
|
538
|
-
* one and a fixed one is a thing to collide with. But the beacon is exactly
|
|
539
|
-
* what does not arrive when this feature is hardest to set up — a router
|
|
540
|
-
* or a firewall in the way is the whole reason the panel has an address
|
|
541
|
-
* field — and then the address somebody typed on the other machine stopped
|
|
542
|
-
* working the next time this deck restarted, with `handshake timed out` and
|
|
543
|
-
* nothing to say the port had simply moved.
|
|
544
|
-
*
|
|
545
|
-
* So the caller keeps one and hands it back, and a port already taken falls
|
|
546
|
-
* straight through to 0 rather than refusing to start. The pin is a
|
|
547
|
-
* preference, never a requirement.
|
|
548
|
-
*/
|
|
549
|
-
start: () => new Promise((resolve, reject) => {
|
|
550
|
-
const wanted = Number.isInteger(prefer) && prefer > 0 && prefer < 65_536 ? prefer : 0;
|
|
551
|
-
let retried = wanted === 0;
|
|
552
|
-
server = net.createServer(onConnection);
|
|
553
|
-
server.on("error", err => {
|
|
554
|
-
if (!retried) {
|
|
555
|
-
// Somebody else has it — another deck on this machine, or something
|
|
556
|
-
// unrelated. The pin is not worth failing to start over.
|
|
557
|
-
retried = true;
|
|
558
|
-
onError?.("listen", err);
|
|
559
|
-
try { server.listen(0, host, () => resolve(server.address().port)); } catch { reject(err); }
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
onError?.("listen", err);
|
|
563
|
-
reject(err);
|
|
564
|
-
});
|
|
565
|
-
server.listen(wanted, host, () => resolve(server.address().port));
|
|
566
|
-
}),
|
|
567
|
-
port: () => server?.address()?.port ?? null,
|
|
568
|
-
stop() {
|
|
569
|
-
for (const s of live) s.destroy();
|
|
570
|
-
live.clear();
|
|
571
|
-
try { server?.close(); } catch { /* not listening */ }
|
|
572
|
-
server = null;
|
|
573
|
-
},
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/**
|
|
578
|
-
* The calling half: connect, prove, be proved to, then talk.
|
|
579
|
-
*
|
|
580
|
-
* BOTH SIDES PROVE, and the second half is the one that is easy to skip. A
|
|
581
|
-
* handshake where only the caller proves itself stops a stranger reading a
|
|
582
|
-
* manifest and stops nothing else — a stranger can still stand up a listener on
|
|
583
|
-
* the announced port, wait for a real deck to dial it, and be handed whatever
|
|
584
|
-
* that deck was going to say. So this checks the reply with the same care the
|
|
585
|
-
* server checks the hello, and gives up if it does not hold.
|
|
586
|
-
*/
|
|
587
|
-
export function connectToPeer({
|
|
588
|
-
host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
|
|
589
|
-
/** The public key we pinned for this deck the first time, or null for a deck
|
|
590
|
-
* we are meeting — an address somebody typed. */
|
|
591
|
-
expectPub = null,
|
|
592
|
-
}) {
|
|
593
|
-
return new Promise((resolve, reject) => {
|
|
594
|
-
const myChallenge = randomBytes(16).toString("hex");
|
|
595
|
-
const sock = net.createConnection({ host, port });
|
|
596
|
-
sock.setEncoding("utf8");
|
|
597
|
-
let settled = false;
|
|
598
|
-
const fail = err => {
|
|
599
|
-
if (settled) return;
|
|
600
|
-
settled = true;
|
|
601
|
-
sock.destroy();
|
|
602
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
603
|
-
};
|
|
604
|
-
const timer = setTimeout(() => fail(new Error("handshake timed out")), timeoutMs);
|
|
605
|
-
timer.unref?.();
|
|
606
|
-
|
|
607
|
-
sock.on("error", fail);
|
|
608
|
-
sock.on("close", () => fail(new Error("peer closed the connection")));
|
|
609
|
-
sock.on("connect", () => {
|
|
610
|
-
// No proof in the hello: the caller cannot cover a challenge it has not
|
|
611
|
-
// been given, and a proof over an empty one would be a proof that means
|
|
612
|
-
// nothing. It goes in message three.
|
|
613
|
-
// `port` is where WE listen, which is not the port this socket came from
|
|
614
|
-
// — that one is ephemeral and useless to dial. Without it a deck can
|
|
615
|
-
// accept an incoming request and still have no way to reach back, so the
|
|
616
|
-
// pairing is mutual on paper and one-way in fact.
|
|
617
|
-
sendFrame(sock, { t: "hello", fp, pub, name, port: myPort, challenge: myChallenge });
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
let theirChallenge = null;
|
|
621
|
-
let theirFp = null;
|
|
622
|
-
let theirPub = null;
|
|
623
|
-
let key = null;
|
|
624
|
-
sock.on("data", frameReader(msg => {
|
|
625
|
-
if (settled) return;
|
|
626
|
-
// A deck that heard us and said no. Each reason is a different problem
|
|
627
|
-
// with a different fix, and until this frame existed they were all one
|
|
628
|
-
// silent close that read as a firewall.
|
|
629
|
-
if (msg.t === "no") {
|
|
630
|
-
return fail(new Error({
|
|
631
|
-
pending: "waiting for the other deck to accept this one",
|
|
632
|
-
declined: "that deck said no",
|
|
633
|
-
impostor: "that deck has this one pinned under a different key",
|
|
634
|
-
"bad proof": "the other deck refused this one's proof",
|
|
635
|
-
}[msg.why] ?? "the other deck refused this handshake"));
|
|
636
|
-
}
|
|
637
|
-
if (msg.t === "challenge") {
|
|
638
|
-
if (theirFp || typeof msg.challenge !== "string") return fail(new Error("bad challenge"));
|
|
639
|
-
const them = readPub(msg.pub);
|
|
640
|
-
if (!them || them.fp !== msg.fp) return fail(new Error("bad challenge"));
|
|
641
|
-
// THE PIN, CHECKED BEFORE ANYTHING ELSE. A deck we have paired with is
|
|
642
|
-
// this key and no other; a key that does not match is not a peer whose
|
|
643
|
-
// details changed, it is a different machine at the same address.
|
|
644
|
-
if (expectPub && expectPub !== them.pub) {
|
|
645
|
-
return fail(new Error("a different deck is answering at that address"));
|
|
646
|
-
}
|
|
647
|
-
theirChallenge = msg.challenge;
|
|
648
|
-
theirFp = them.fp;
|
|
649
|
-
theirPub = them.pub;
|
|
650
|
-
key = sessionKey(secret, theirPub, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge));
|
|
651
|
-
sendFrame(sock, {
|
|
652
|
-
t: "auth",
|
|
653
|
-
proof: proof(key, {
|
|
654
|
-
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
655
|
-
fromFp: fp, toFp: theirFp, direction: "hello",
|
|
656
|
-
}),
|
|
657
|
-
// Only when joining on an invite. Sent in the same frame as the
|
|
658
|
-
// session proof so a deck that holds a token is paired in one round
|
|
659
|
-
// trip rather than being queued behind somebody else's press.
|
|
660
|
-
...(code ? { invite: inviteProof(code, handshakeTranscript(fp, theirFp, myChallenge, theirChallenge)) } : {}),
|
|
661
|
-
});
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
// The same deck that gave us the challenge, or nothing: a reply naming a
|
|
665
|
-
// different fingerprint is a second party in the middle of this.
|
|
666
|
-
if (msg.t !== "ok" || msg.fp !== theirFp) return fail(new Error("expected ok"));
|
|
667
|
-
const want = proof(key, {
|
|
668
|
-
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
669
|
-
fromFp: theirFp, toFp: fp, direction: "reply",
|
|
670
|
-
});
|
|
671
|
-
if (!proofOk(want, msg.proof)) return fail(new Error("that deck could not prove its own key"));
|
|
672
|
-
settled = true;
|
|
673
|
-
clearTimeout(timer);
|
|
674
|
-
sock.removeAllListeners("close");
|
|
675
|
-
resolve({
|
|
676
|
-
sock, key,
|
|
677
|
-
peerFp: theirFp, peerPub: theirPub, peerName: msg.name,
|
|
678
|
-
send: obj => sendFrame(sock, obj),
|
|
679
|
-
});
|
|
680
|
-
}, fail));
|
|
681
|
-
});
|
|
682
|
-
}
|