agent-comms 1.33.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -2
- package/dist/core/mesh-store.d.ts +2 -2
- package/dist/core/mesh-store.d.ts.map +1 -1
- package/dist/core/mesh-store.js +2 -2
- package/dist/core/mesh-store.js.map +1 -1
- package/dist/core/wire-mesh-transport.d.ts +1 -1
- package/dist/core/wire-mesh-transport.d.ts.map +1 -1
- package/dist/core/wire-mesh-transport.js +11 -9
- package/dist/core/wire-mesh-transport.js.map +1 -1
- package/dist/core/wire-protocol.d.ts +1 -12
- package/dist/core/wire-protocol.d.ts.map +1 -1
- package/dist/core/wire-protocol.js +1 -1
- package/dist/core/wire-protocol.js.map +1 -1
- package/dist/test/approval.integration.test.js +54 -0
- package/dist/test/approval.integration.test.js.map +1 -1
- package/dist/test/become-coordinator-actual-port.integration.test.d.ts +2 -2
- package/dist/test/become-coordinator-actual-port.integration.test.js +38 -15
- package/dist/test/become-coordinator-actual-port.integration.test.js.map +1 -1
- package/dist/test/bridge-mesh.test.js +1 -1
- package/dist/test/bridge-mesh.test.js.map +1 -1
- package/dist/test/broadcast-window.integration.test.d.ts +2 -2
- package/dist/test/broadcast-window.integration.test.js +7 -6
- package/dist/test/broadcast-window.integration.test.js.map +1 -1
- package/dist/test/downtime-replay.integration.test.js +6 -5
- package/dist/test/downtime-replay.integration.test.js.map +1 -1
- package/dist/test/identity-restart.integration.test.d.ts +1 -1
- package/dist/test/identity-restart.integration.test.js +7 -6
- package/dist/test/identity-restart.integration.test.js.map +1 -1
- package/dist/test/listener-policy.integration.test.js +45 -44
- package/dist/test/listener-policy.integration.test.js.map +1 -1
- package/dist/test/mesh-smoke.runner.d.ts +1 -1
- package/dist/test/mesh-smoke.runner.js +5 -4
- package/dist/test/mesh-smoke.runner.js.map +1 -1
- package/dist/test/test-transport.d.ts +1 -5
- package/dist/test/test-transport.d.ts.map +1 -1
- package/dist/test/test-transport.js +3 -15
- package/dist/test/test-transport.js.map +1 -1
- package/package.json +1 -1
- package/dist/core/tls-transport.d.ts +0 -86
- package/dist/core/tls-transport.d.ts.map +0 -1
- package/dist/core/tls-transport.js +0 -758
- package/dist/core/tls-transport.js.map +0 -1
- package/dist/test/peer-id-verification.integration.test.d.ts +0 -7
- package/dist/test/peer-id-verification.integration.test.d.ts.map +0 -1
- package/dist/test/peer-id-verification.integration.test.js +0 -176
- package/dist/test/peer-id-verification.integration.test.js.map +0 -1
- package/dist/test/tls-transport.integration.test.d.ts +0 -9
- package/dist/test/tls-transport.integration.test.d.ts.map +0 -1
- package/dist/test/tls-transport.integration.test.js +0 -160
- package/dist/test/tls-transport.integration.test.js.map +0 -1
- package/dist/test/wire-mesh-transport-approval.integration.test.d.ts +0 -5
- package/dist/test/wire-mesh-transport-approval.integration.test.d.ts.map +0 -1
- package/dist/test/wire-mesh-transport-approval.integration.test.js +0 -214
- package/dist/test/wire-mesh-transport-approval.integration.test.js.map +0 -1
- package/dist/test/wire-mesh-transport.integration.test.d.ts +0 -5
- package/dist/test/wire-mesh-transport.integration.test.d.ts.map +0 -1
- package/dist/test/wire-mesh-transport.integration.test.js +0 -89
- package/dist/test/wire-mesh-transport.integration.test.js.map +0 -1
|
@@ -1,758 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TlsTransport — TLS-encrypted transport for the peer mesh, the one
|
|
3
|
-
* production MeshTransport implementation.
|
|
4
|
-
*
|
|
5
|
-
* All connections are wrapped in TLS with certificate fingerprint
|
|
6
|
-
* authentication. Each peer generates an ECDSA P-256 keypair and self-signed
|
|
7
|
-
* X.509 certificate on startup. The peer ID is the certificate fingerprint —
|
|
8
|
-
* verifying a peer's identity is simply checking that the presented
|
|
9
|
-
* certificate's fingerprint matches the known ID.
|
|
10
|
-
*
|
|
11
|
-
* This is the Syncthing trust model: no CA, no PKI, just certificate pinning.
|
|
12
|
-
* Overlay networks (Tailscale, WireGuard) are still recommended for NAT
|
|
13
|
-
* traversal but are not required for security — TLS handles encryption and
|
|
14
|
-
* authentication at the protocol level.
|
|
15
|
-
*
|
|
16
|
-
* Implements the same MeshTransport interface every transport does. Same
|
|
17
|
-
* wire protocol, same event callbacks — MeshStore only ever sees the
|
|
18
|
-
* interface, never a concrete transport.
|
|
19
|
-
*/
|
|
20
|
-
import * as tls from "node:tls";
|
|
21
|
-
import { encode, isMeshMessage, MessageBuffer } from "./wire-protocol.js";
|
|
22
|
-
import { attachSocketHandshake } from "./handshake.js";
|
|
23
|
-
import { fingerprintDer } from "./identity.js";
|
|
24
|
-
import { nanoid } from "./nanoid.js";
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Constants
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
const COORDINATOR_HOST = "127.0.0.1";
|
|
29
|
-
const CONNECT_TIMEOUT_MS = 1000;
|
|
30
|
-
/**
|
|
31
|
-
* Wrap tls.createServer with a retry for intermittent OpenSSL ASN.1 races
|
|
32
|
-
* that occur when parallel test workers create TLS servers simultaneously.
|
|
33
|
-
* Up to 3 attempts before propagating the error.
|
|
34
|
-
*/
|
|
35
|
-
function retryCreateTlsServer(options, callback) {
|
|
36
|
-
let attempts = 0;
|
|
37
|
-
const maxAttempts = 3;
|
|
38
|
-
const tryCreate = () => {
|
|
39
|
-
attempts++;
|
|
40
|
-
try {
|
|
41
|
-
return tls.createServer(options, callback);
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
if (attempts < maxAttempts)
|
|
45
|
-
return tryCreate();
|
|
46
|
-
throw new Error(`tls.createServer failed after ${String(attempts)} attempts`);
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
return tryCreate();
|
|
50
|
-
}
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Async socket write helper (not exported)
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
/**
|
|
55
|
-
* Safety valve so a dial that never completes cannot grow its outbound queue
|
|
56
|
-
* unbounded; oldest entries are dropped first (#23).
|
|
57
|
-
*/
|
|
58
|
-
const MAX_PENDING_PER_PEER = 100;
|
|
59
|
-
function writeAsync(socket, data) {
|
|
60
|
-
return new Promise((resolve, reject) => {
|
|
61
|
-
if (socket.destroyed) {
|
|
62
|
-
resolve();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
socket.write(data, "utf-8", (err) => {
|
|
66
|
-
if (err)
|
|
67
|
-
reject(err);
|
|
68
|
-
else
|
|
69
|
-
resolve();
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* writeAsync(), but a failed write (the peer disconnected, or its own shutdown destroyed the socket mid-write) resolves instead of rejecting -- cleanup is already handled by the socket's own close/error listeners wherever it's registered, exactly the same ordinary-and-expected outcome broadcast() already treats this way. A caller that genuinely needs to know whether the write landed gets that back as a boolean rather than an exception, since a write racing a legitimate connection teardown is not itself a program error and must never surface as an unhandled rejection.
|
|
75
|
-
*/
|
|
76
|
-
async function writeBestEffort(socket, data) {
|
|
77
|
-
return writeAsync(socket, data).then(() => true, () => false);
|
|
78
|
-
}
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
// TlsTransport
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
export class TlsTransport {
|
|
83
|
-
// -- Data server (accepts incoming peer data connections over TLS) --
|
|
84
|
-
dataServer;
|
|
85
|
-
_dataPort = 0;
|
|
86
|
-
// -- Coordinator listeners (multiple adapters) --
|
|
87
|
-
coordinatorListeners = new Map();
|
|
88
|
-
/** The default localhost listener ID (set once during becomeCoordinator). */
|
|
89
|
-
defaultListenerId;
|
|
90
|
-
_isCoordinator = false;
|
|
91
|
-
// -- Coordinator client socket (TLS connection to the coordinator) --
|
|
92
|
-
coordinatorSocket;
|
|
93
|
-
// -- Messages queued for peers whose dial is still in flight (#23) --
|
|
94
|
-
pendingOutbound = new Map();
|
|
95
|
-
// -- Coordinator introduction handshake (resolved on the peer list) --
|
|
96
|
-
resolveCoordinatorHandshake;
|
|
97
|
-
coordinatorHandshakeTimer;
|
|
98
|
-
// -- Peer data connections (peer ID → socket + buffer) --
|
|
99
|
-
peerConnections = new Map();
|
|
100
|
-
// -- All sockets accepted by the data server (for shutdown cleanup) --
|
|
101
|
-
dataServerSockets = new Set();
|
|
102
|
-
// -- All sockets accepted by the coordinator server (for shutdown cleanup) --
|
|
103
|
-
coordinatorServerSockets = new Set();
|
|
104
|
-
// -- Coordinator introduction connections (handle ID → socket) --
|
|
105
|
-
introConnections = new Map();
|
|
106
|
-
// -- Pending connections awaiting approval (handle ID → socket + request info) --
|
|
107
|
-
pendingConnections = new Map();
|
|
108
|
-
// -- Outbound connectToPeer sockets still mid-dial, not yet in peerConnections or already failed — tracked so shutdown() can destroy an in-flight dial rather than leaving it running in the background indefinitely, long after this transport is gone --
|
|
109
|
-
pendingConnectSockets = new Set();
|
|
110
|
-
// -- Peers this side has dialled out to (in flight or established) — deliberately separate from peerConnections, which also holds sockets THIS side merely accepted an inbound dial from and identified via pong. A peer can legitimately be dialling us at the same time we need to dial them (mesh formation establishes one connection in each direction so each side's acceptor can push its own state), so connectToPeer's own "don't dial twice" guard must not be satisfied by an inbound connection that happens to share the same peer ID. --
|
|
111
|
-
outboundDials = new Set();
|
|
112
|
-
// -- Shutdown sentinel — prevents callbacks after shutdown() --
|
|
113
|
-
shutDown = false;
|
|
114
|
-
// -- This peer's ID (set during connectToCoordinator or becomeCoordinator) --
|
|
115
|
-
_peerId = "";
|
|
116
|
-
events;
|
|
117
|
-
identity;
|
|
118
|
-
constructor(events, identity) {
|
|
119
|
-
this.events = events;
|
|
120
|
-
this.identity = identity;
|
|
121
|
-
}
|
|
122
|
-
// -- Public getters for interface properties --
|
|
123
|
-
get dataPort() {
|
|
124
|
-
return this._dataPort;
|
|
125
|
-
}
|
|
126
|
-
get isCoordinator() {
|
|
127
|
-
return this._isCoordinator;
|
|
128
|
-
}
|
|
129
|
-
get hasCoordinatorConnection() {
|
|
130
|
-
return (this.coordinatorSocket !== undefined && !this.coordinatorSocket.destroyed);
|
|
131
|
-
}
|
|
132
|
-
// -----------------------------------------------------------------------
|
|
133
|
-
// TLS options
|
|
134
|
-
// -----------------------------------------------------------------------
|
|
135
|
-
get tlsOptions() {
|
|
136
|
-
return {
|
|
137
|
-
key: this.identity.privateKey,
|
|
138
|
-
cert: this.identity.certificate,
|
|
139
|
-
// Do not reject unauthorized — we do our own fingerprint verification after the TLS handshake completes, in verifyClaimedPeerId().
|
|
140
|
-
rejectUnauthorized: false,
|
|
141
|
-
requestCert: true,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
get connectOptions() {
|
|
145
|
-
return {
|
|
146
|
-
key: this.identity.privateKey,
|
|
147
|
-
cert: this.identity.certificate,
|
|
148
|
-
rejectUnauthorized: false,
|
|
149
|
-
// Don't verify server cert via CA — verify via fingerprint pinning
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
// -----------------------------------------------------------------------
|
|
153
|
-
// Peer identity verification
|
|
154
|
-
// -----------------------------------------------------------------------
|
|
155
|
-
/**
|
|
156
|
-
* Verify a connected socket's presented certificate fingerprint matches the peer ID it claims via `introduce`/`pong` in the wire protocol, destroying the socket and returning false on any mismatch (including no certificate presented at all).
|
|
157
|
-
*
|
|
158
|
-
* Peer IDs are minted as the fingerprint of the peer's own certificate (identity.ts's `generateIdentity()`, wired up by every bridge as `store.peerId = identity.fingerprint`), so a claimed peer ID that doesn't match the certificate actually presented on this connection means the socket is not who it says it is — regardless of what it typed into the wire message.
|
|
159
|
-
*/
|
|
160
|
-
verifyClaimedPeerId(socket, claimedPeerId) {
|
|
161
|
-
const cert = socket.getPeerCertificate();
|
|
162
|
-
// Node's types declare every PeerCertificate field non-optional, but the documented runtime behaviour when the peer presents no certificate at all is an empty object — not null/undefined, and not a Buffer-typed `raw`. Detect that real shape rather than trusting the declared type.
|
|
163
|
-
if (Object.keys(cert).length === 0) {
|
|
164
|
-
socket.destroy();
|
|
165
|
-
this.events.onError?.(new Error(`Rejected connection claiming peer ID ${claimedPeerId}: no certificate presented`));
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
const actualFingerprint = fingerprintDer(cert.raw);
|
|
169
|
-
if (actualFingerprint !== claimedPeerId) {
|
|
170
|
-
socket.destroy();
|
|
171
|
-
this.events.onError?.(new Error(`Rejected connection claiming peer ID ${claimedPeerId}: presented certificate fingerprint is ${actualFingerprint}`));
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
return true;
|
|
175
|
-
}
|
|
176
|
-
// -----------------------------------------------------------------------
|
|
177
|
-
// MeshTransport — Data server
|
|
178
|
-
// -----------------------------------------------------------------------
|
|
179
|
-
async startDataServer() {
|
|
180
|
-
await new Promise((resolve, reject) => {
|
|
181
|
-
this.dataServer = retryCreateTlsServer(this.tlsOptions, (socket) => {
|
|
182
|
-
this.handleIncomingDataConnection(socket);
|
|
183
|
-
});
|
|
184
|
-
this.dataServer.listen(0, COORDINATOR_HOST, () => {
|
|
185
|
-
const addr = this.dataServer?.address();
|
|
186
|
-
if (typeof addr === "object" && addr !== null) {
|
|
187
|
-
this._dataPort = addr.port;
|
|
188
|
-
}
|
|
189
|
-
resolve();
|
|
190
|
-
});
|
|
191
|
-
this.dataServer.on("error", reject);
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
// -----------------------------------------------------------------------
|
|
195
|
-
// MeshTransport — Coordinator connection (client side)
|
|
196
|
-
// -----------------------------------------------------------------------
|
|
197
|
-
async connectToCoordinator(host, port, peerId, localDataPort) {
|
|
198
|
-
this._peerId = peerId;
|
|
199
|
-
await new Promise((resolve, reject) => {
|
|
200
|
-
const socket = tls.connect({ ...this.connectOptions, host, port }, () => {
|
|
201
|
-
this.coordinatorSocket = socket;
|
|
202
|
-
// Wire up the protocol handshake first (client role) so the introduction below lands after it on the wire, not before.
|
|
203
|
-
const buffer = new MessageBuffer();
|
|
204
|
-
attachSocketHandshake(socket, "client", (data) => {
|
|
205
|
-
const items = buffer.append(data.toString());
|
|
206
|
-
for (const item of items) {
|
|
207
|
-
if (isMeshMessage(item)) {
|
|
208
|
-
this.dispatchCoordinatorClientMessage(item);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}, (error) => this.events.onError?.(error));
|
|
212
|
-
// Send introduction
|
|
213
|
-
const intro = {
|
|
214
|
-
method: "introduce",
|
|
215
|
-
peerId,
|
|
216
|
-
dataPort: localDataPort,
|
|
217
|
-
};
|
|
218
|
-
socket.write(encode(intro));
|
|
219
|
-
socket.on("error", () => {
|
|
220
|
-
/* ignore late errors on coordinator connection */
|
|
221
|
-
});
|
|
222
|
-
clearTimeout(timer);
|
|
223
|
-
// Resolve on the coordinator's peer list rather than on sending the
|
|
224
|
-
// introduction: MeshStore.init() then returns only after the post-join
|
|
225
|
-
// peer dials have started, so the first broadcasts are queued for the
|
|
226
|
-
// dialling peers instead of dropped (#23). A timeout keeps today's
|
|
227
|
-
// degraded behaviour for a coordinator that never answers.
|
|
228
|
-
this.resolveCoordinatorHandshake = resolve;
|
|
229
|
-
this.coordinatorHandshakeTimer = setTimeout(() => {
|
|
230
|
-
this.resolveCoordinatorHandshake = undefined;
|
|
231
|
-
resolve();
|
|
232
|
-
}, CONNECT_TIMEOUT_MS);
|
|
233
|
-
});
|
|
234
|
-
const timer = setTimeout(() => {
|
|
235
|
-
socket.destroy();
|
|
236
|
-
reject(new Error("Coordinator connection timeout"));
|
|
237
|
-
}, CONNECT_TIMEOUT_MS);
|
|
238
|
-
socket.on("error", (err) => {
|
|
239
|
-
clearTimeout(timer);
|
|
240
|
-
socket.destroy();
|
|
241
|
-
reject(err);
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
// -----------------------------------------------------------------------
|
|
246
|
-
// MeshTransport — Connect to remote coordinator with approval
|
|
247
|
-
// -----------------------------------------------------------------------
|
|
248
|
-
async connectToRemote(host, port, peerId, localDataPort, name, fingerprint) {
|
|
249
|
-
this._peerId = peerId;
|
|
250
|
-
await new Promise((resolve, reject) => {
|
|
251
|
-
const socket = tls.connect({ ...this.connectOptions, host, port }, () => {
|
|
252
|
-
this.coordinatorSocket = socket;
|
|
253
|
-
clearTimeout(timer);
|
|
254
|
-
// Wire up the protocol handshake first (client role) so connect_request below lands after it on the wire, not before.
|
|
255
|
-
const buffer = new MessageBuffer();
|
|
256
|
-
let approved = false;
|
|
257
|
-
attachSocketHandshake(socket, "client", (data) => {
|
|
258
|
-
const items = buffer.append(data.toString());
|
|
259
|
-
for (const item of items) {
|
|
260
|
-
if (!isMeshMessage(item))
|
|
261
|
-
continue;
|
|
262
|
-
if (!approved) {
|
|
263
|
-
if (item.method === "connect_accepted") {
|
|
264
|
-
approved = true;
|
|
265
|
-
resolve();
|
|
266
|
-
}
|
|
267
|
-
else if (item.method === "connect_rejected") {
|
|
268
|
-
socket.destroy();
|
|
269
|
-
reject(new Error(`Connection rejected: ${item.reason}`));
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
this.dispatchCoordinatorClientMessage(item);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}, (error) => this.events.onError?.(error));
|
|
278
|
-
// Send connect_request instead of introduce
|
|
279
|
-
const req = {
|
|
280
|
-
method: "connect_request",
|
|
281
|
-
peerId,
|
|
282
|
-
dataPort: localDataPort,
|
|
283
|
-
name,
|
|
284
|
-
fingerprint,
|
|
285
|
-
};
|
|
286
|
-
socket.write(encode(req));
|
|
287
|
-
socket.on("error", () => {
|
|
288
|
-
/* ignore late errors on coordinator connection */
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
const timer = setTimeout(() => {
|
|
292
|
-
socket.destroy();
|
|
293
|
-
reject(new Error("Remote connection timeout"));
|
|
294
|
-
}, CONNECT_TIMEOUT_MS);
|
|
295
|
-
socket.on("error", (err) => {
|
|
296
|
-
clearTimeout(timer);
|
|
297
|
-
socket.destroy();
|
|
298
|
-
reject(err);
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
// -----------------------------------------------------------------------
|
|
303
|
-
// MeshTransport — Become coordinator (server side)
|
|
304
|
-
// -----------------------------------------------------------------------
|
|
305
|
-
async becomeCoordinator(host, port) {
|
|
306
|
-
const id = nanoid(8);
|
|
307
|
-
await new Promise((resolve, reject) => {
|
|
308
|
-
const server = retryCreateTlsServer(this.tlsOptions, (socket) => {
|
|
309
|
-
this.handleCoordinatorServerConnection(socket, "full");
|
|
310
|
-
});
|
|
311
|
-
server.listen(port, host, () => {
|
|
312
|
-
const addr = server.address();
|
|
313
|
-
const actualPort = typeof addr === "object" && addr !== null ? addr.port : port;
|
|
314
|
-
this._isCoordinator = true;
|
|
315
|
-
this.coordinatorListeners.set(id, {
|
|
316
|
-
server,
|
|
317
|
-
policy: "full",
|
|
318
|
-
host,
|
|
319
|
-
port: actualPort,
|
|
320
|
-
isDefault: true,
|
|
321
|
-
});
|
|
322
|
-
this.defaultListenerId = id;
|
|
323
|
-
resolve();
|
|
324
|
-
});
|
|
325
|
-
server.on("error", (err) => {
|
|
326
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
// -----------------------------------------------------------------------
|
|
331
|
-
// MeshTransport — Multi-listener management
|
|
332
|
-
// -----------------------------------------------------------------------
|
|
333
|
-
async addListener(host, port, policy) {
|
|
334
|
-
if (!this._isCoordinator) {
|
|
335
|
-
throw new Error("Only the coordinator can add listeners");
|
|
336
|
-
}
|
|
337
|
-
const id = nanoid(8);
|
|
338
|
-
await new Promise((resolve, reject) => {
|
|
339
|
-
const server = retryCreateTlsServer(this.tlsOptions, (socket) => {
|
|
340
|
-
this.handleCoordinatorServerConnection(socket, policy);
|
|
341
|
-
});
|
|
342
|
-
server.listen(port, host, () => {
|
|
343
|
-
const addr = server.address();
|
|
344
|
-
const actualPort = typeof addr === "object" && addr !== null ? addr.port : port;
|
|
345
|
-
this.coordinatorListeners.set(id, {
|
|
346
|
-
server,
|
|
347
|
-
policy,
|
|
348
|
-
host,
|
|
349
|
-
port: actualPort,
|
|
350
|
-
isDefault: false,
|
|
351
|
-
});
|
|
352
|
-
resolve();
|
|
353
|
-
});
|
|
354
|
-
server.on("error", (err) => {
|
|
355
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
return id;
|
|
359
|
-
}
|
|
360
|
-
removeListener(id) {
|
|
361
|
-
const listener = this.coordinatorListeners.get(id);
|
|
362
|
-
if (!listener) {
|
|
363
|
-
throw new Error(`Listener ${id} not found`);
|
|
364
|
-
}
|
|
365
|
-
if (listener.isDefault) {
|
|
366
|
-
throw new Error("Cannot remove the default localhost listener");
|
|
367
|
-
}
|
|
368
|
-
listener.server.unref();
|
|
369
|
-
listener.server.close();
|
|
370
|
-
this.coordinatorListeners.delete(id);
|
|
371
|
-
return Promise.resolve();
|
|
372
|
-
}
|
|
373
|
-
listListeners() {
|
|
374
|
-
const result = [];
|
|
375
|
-
for (const [id, listener] of this.coordinatorListeners) {
|
|
376
|
-
result.push({
|
|
377
|
-
id,
|
|
378
|
-
host: listener.host,
|
|
379
|
-
port: listener.port,
|
|
380
|
-
policy: listener.policy,
|
|
381
|
-
isDefault: listener.isDefault,
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
return result;
|
|
385
|
-
}
|
|
386
|
-
// -----------------------------------------------------------------------
|
|
387
|
-
// MeshTransport — Connect to a peer's data server
|
|
388
|
-
// -----------------------------------------------------------------------
|
|
389
|
-
async connectToPeer(peer, ownPeerId) {
|
|
390
|
-
if (this.shutDown || this.outboundDials.has(peer.id))
|
|
391
|
-
return;
|
|
392
|
-
this.outboundDials.add(peer.id);
|
|
393
|
-
// Queue broadcasts until the connection registers: messages sent in the dial window previously had nowhere to go and were silently dropped.
|
|
394
|
-
this.pendingOutbound.set(peer.id, this.pendingOutbound.get(peer.id) ?? []);
|
|
395
|
-
await new Promise((resolve) => {
|
|
396
|
-
const socket = tls.connect({ ...this.connectOptions, host: COORDINATOR_HOST, port: peer.port }, () => {
|
|
397
|
-
this.pendingConnectSockets.delete(socket);
|
|
398
|
-
if (this.shutDown) {
|
|
399
|
-
this.pendingOutbound.delete(peer.id);
|
|
400
|
-
socket.destroy();
|
|
401
|
-
resolve();
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
if (!this.verifyClaimedPeerId(socket, peer.id)) {
|
|
405
|
-
this.pendingOutbound.delete(peer.id);
|
|
406
|
-
this.outboundDials.delete(peer.id);
|
|
407
|
-
resolve();
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
const buffer = new MessageBuffer();
|
|
411
|
-
this.peerConnections.set(peer.id, { socket, buffer });
|
|
412
|
-
// Wire up the protocol handshake first (client role) so everything below lands after it on the wire, not before.
|
|
413
|
-
attachSocketHandshake(socket, "client", (data) => {
|
|
414
|
-
const items = buffer.append(data.toString());
|
|
415
|
-
for (const item of items) {
|
|
416
|
-
if (isMeshMessage(item)) {
|
|
417
|
-
const handle = { id: peer.id };
|
|
418
|
-
this.dispatchDataMessage(handle, item);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}, (error) => this.events.onError?.(error));
|
|
422
|
-
// Identify ourselves
|
|
423
|
-
const pong = { method: "pong", peerId: ownPeerId };
|
|
424
|
-
socket.write(encode(pong));
|
|
425
|
-
void this.flushPending(peer.id, socket);
|
|
426
|
-
resolve();
|
|
427
|
-
});
|
|
428
|
-
// Tracked from the moment tls.connect() returns (before the connect callback above ever fires) so shutdown() can destroy a dial that's still mid-handshake — otherwise it keeps running in the background with nothing to cancel it, eventually erroring out (or not) long after this transport, and the test or process that started it, are gone.
|
|
429
|
-
this.pendingConnectSockets.add(socket);
|
|
430
|
-
const handle = { id: peer.id };
|
|
431
|
-
let disconnected = false;
|
|
432
|
-
const onDisconnect = () => {
|
|
433
|
-
if (disconnected)
|
|
434
|
-
return;
|
|
435
|
-
disconnected = true;
|
|
436
|
-
this.pendingConnectSockets.delete(socket);
|
|
437
|
-
this.outboundDials.delete(peer.id);
|
|
438
|
-
// Only remove peerConnections' entry if it still points at THIS socket -- the peer may also have dialled us independently (a second, inbound connection for the same peer ID), and that connection's own close must never delete a live entry this one already replaced, or vice versa.
|
|
439
|
-
const current = this.peerConnections.get(peer.id);
|
|
440
|
-
const wasConnected = current?.socket === socket;
|
|
441
|
-
if (wasConnected)
|
|
442
|
-
this.peerConnections.delete(peer.id);
|
|
443
|
-
if (wasConnected && !this.shutDown) {
|
|
444
|
-
this.events.onPeerDisconnected(handle);
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
|
-
socket.on("close", onDisconnect);
|
|
448
|
-
socket.on("error", (err) => {
|
|
449
|
-
if (!this.shutDown) {
|
|
450
|
-
this.events.onError?.(new Error(`connectToPeer(${peer.id}, port ${String(peer.port)}) socket error: ${err.message}`));
|
|
451
|
-
}
|
|
452
|
-
this.pendingOutbound.delete(peer.id);
|
|
453
|
-
onDisconnect();
|
|
454
|
-
socket.destroy();
|
|
455
|
-
resolve();
|
|
456
|
-
});
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
// -----------------------------------------------------------------------
|
|
460
|
-
// MeshTransport — Send / broadcast
|
|
461
|
-
// -----------------------------------------------------------------------
|
|
462
|
-
async send(handle, message) {
|
|
463
|
-
// Check data connections first
|
|
464
|
-
const peerConn = this.peerConnections.get(handle.id);
|
|
465
|
-
if (peerConn) {
|
|
466
|
-
await writeBestEffort(peerConn.socket, encode(message));
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
// Check coordinator introduction connections
|
|
470
|
-
const introSocket = this.introConnections.get(handle.id);
|
|
471
|
-
if (introSocket) {
|
|
472
|
-
await writeBestEffort(introSocket, encode(message));
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
throw new Error(`No connection for handle ${handle.id}`);
|
|
476
|
-
}
|
|
477
|
-
async acceptConnection(handle) {
|
|
478
|
-
const pending = this.pendingConnections.get(handle.id);
|
|
479
|
-
if (!pending) {
|
|
480
|
-
throw new Error(`No pending connection for handle ${handle.id}`);
|
|
481
|
-
}
|
|
482
|
-
this.pendingConnections.delete(handle.id);
|
|
483
|
-
const { socket, peerId, dataPort, policy } = pending;
|
|
484
|
-
// Move to introConnections so send() can reach this peer
|
|
485
|
-
this.introConnections.set(peerId, socket);
|
|
486
|
-
// Send acceptance to the connecting peer -- best-effort: the peer having already vanished (or this side's own concurrent shutdown destroying the socket mid-write) is an ordinary disconnect, not a program error, and must not surface as an unhandled rejection once onIntroduction's own downstream state-sync work below is already in flight.
|
|
487
|
-
const accepted = {
|
|
488
|
-
method: "connect_accepted",
|
|
489
|
-
peerId: this._peerId,
|
|
490
|
-
dataPort: this._dataPort,
|
|
491
|
-
};
|
|
492
|
-
await writeBestEffort(socket, encode(accepted));
|
|
493
|
-
// Fire onIntroduction so MeshStore processes the new peer normally
|
|
494
|
-
const connHandle = { id: peerId, policy };
|
|
495
|
-
this.events.onIntroduction(connHandle, { peerId, dataPort });
|
|
496
|
-
}
|
|
497
|
-
async rejectConnection(handle, reason) {
|
|
498
|
-
const pending = this.pendingConnections.get(handle.id);
|
|
499
|
-
if (!pending) {
|
|
500
|
-
throw new Error(`No pending connection for handle ${handle.id}`);
|
|
501
|
-
}
|
|
502
|
-
this.pendingConnections.delete(handle.id);
|
|
503
|
-
const { socket } = pending;
|
|
504
|
-
const rejected = {
|
|
505
|
-
method: "connect_rejected",
|
|
506
|
-
peerId: handle.id,
|
|
507
|
-
reason,
|
|
508
|
-
};
|
|
509
|
-
await writeBestEffort(socket, encode(rejected));
|
|
510
|
-
socket.destroy();
|
|
511
|
-
}
|
|
512
|
-
async broadcast(message) {
|
|
513
|
-
const data = encode(message);
|
|
514
|
-
const writes = [];
|
|
515
|
-
for (const [, peer] of this.peerConnections) {
|
|
516
|
-
writes.push(writeBestEffort(peer.socket, data).then(() => undefined));
|
|
517
|
-
}
|
|
518
|
-
for (const queue of this.pendingOutbound.values()) {
|
|
519
|
-
queue.push(message);
|
|
520
|
-
if (queue.length > MAX_PENDING_PER_PEER)
|
|
521
|
-
queue.shift();
|
|
522
|
-
}
|
|
523
|
-
await Promise.all(writes);
|
|
524
|
-
}
|
|
525
|
-
/** Send messages queued while the peer's connection was being dialled. */
|
|
526
|
-
async flushPending(peerId, socket) {
|
|
527
|
-
const queue = this.pendingOutbound.get(peerId);
|
|
528
|
-
this.pendingOutbound.delete(peerId);
|
|
529
|
-
if (queue === undefined)
|
|
530
|
-
return;
|
|
531
|
-
for (const message of queue) {
|
|
532
|
-
const sent = await writeBestEffort(socket, encode(message));
|
|
533
|
-
if (!sent)
|
|
534
|
-
return; // connection is dying; close/error listeners clean up
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
// -----------------------------------------------------------------------
|
|
538
|
-
// MeshTransport — Shutdown / unref
|
|
539
|
-
// -----------------------------------------------------------------------
|
|
540
|
-
shutdown() {
|
|
541
|
-
this.shutDown = true;
|
|
542
|
-
if (this.coordinatorHandshakeTimer !== undefined) {
|
|
543
|
-
clearTimeout(this.coordinatorHandshakeTimer);
|
|
544
|
-
this.coordinatorHandshakeTimer = undefined;
|
|
545
|
-
}
|
|
546
|
-
this.resolveCoordinatorHandshake = undefined;
|
|
547
|
-
// Destroy the coordinator client socket
|
|
548
|
-
this.coordinatorSocket?.unref();
|
|
549
|
-
this.coordinatorSocket?.destroy();
|
|
550
|
-
this.coordinatorSocket = undefined;
|
|
551
|
-
// Destroy all identified peer connections
|
|
552
|
-
for (const [, peer] of this.peerConnections) {
|
|
553
|
-
peer.socket.unref();
|
|
554
|
-
peer.socket.destroy();
|
|
555
|
-
}
|
|
556
|
-
this.peerConnections.clear();
|
|
557
|
-
// Destroy all data server accepted sockets (including unidentified)
|
|
558
|
-
for (const socket of this.dataServerSockets) {
|
|
559
|
-
socket.unref();
|
|
560
|
-
socket.destroy();
|
|
561
|
-
}
|
|
562
|
-
this.dataServerSockets.clear();
|
|
563
|
-
// Destroy all coordinator server accepted sockets
|
|
564
|
-
for (const socket of this.coordinatorServerSockets) {
|
|
565
|
-
socket.unref();
|
|
566
|
-
socket.destroy();
|
|
567
|
-
}
|
|
568
|
-
this.coordinatorServerSockets.clear();
|
|
569
|
-
// Clear introduction connection tracking
|
|
570
|
-
this.introConnections.clear();
|
|
571
|
-
// Clear pending connections
|
|
572
|
-
for (const [, pending] of this.pendingConnections) {
|
|
573
|
-
pending.socket.unref();
|
|
574
|
-
pending.socket.destroy();
|
|
575
|
-
}
|
|
576
|
-
this.pendingConnections.clear();
|
|
577
|
-
// Destroy any outbound connectToPeer dials still mid-flight — without this they keep retrying/erroring out in the background indefinitely, long after this transport (and whatever test or process started it) is gone.
|
|
578
|
-
for (const socket of this.pendingConnectSockets) {
|
|
579
|
-
socket.unref();
|
|
580
|
-
socket.destroy();
|
|
581
|
-
}
|
|
582
|
-
this.pendingConnectSockets.clear();
|
|
583
|
-
// Close data server
|
|
584
|
-
this.dataServer?.unref();
|
|
585
|
-
this.dataServer?.close();
|
|
586
|
-
this.dataServer = undefined;
|
|
587
|
-
// Close coordinator listener servers
|
|
588
|
-
for (const [, listener] of this.coordinatorListeners) {
|
|
589
|
-
listener.server.unref();
|
|
590
|
-
listener.server.close();
|
|
591
|
-
}
|
|
592
|
-
this.coordinatorListeners.clear();
|
|
593
|
-
this.defaultListenerId = undefined;
|
|
594
|
-
this._isCoordinator = false;
|
|
595
|
-
return Promise.resolve();
|
|
596
|
-
}
|
|
597
|
-
unref() {
|
|
598
|
-
this.dataServer?.unref();
|
|
599
|
-
for (const [, listener] of this.coordinatorListeners) {
|
|
600
|
-
listener.server.unref();
|
|
601
|
-
}
|
|
602
|
-
this.coordinatorSocket?.unref();
|
|
603
|
-
}
|
|
604
|
-
// -----------------------------------------------------------------------
|
|
605
|
-
// Internal — Coordinator client message dispatch
|
|
606
|
-
// -----------------------------------------------------------------------
|
|
607
|
-
dispatchCoordinatorClientMessage(msg) {
|
|
608
|
-
if (this.shutDown)
|
|
609
|
-
return;
|
|
610
|
-
if (msg.method === "peer_list") {
|
|
611
|
-
// Fire onPeerList first: it starts the post-join dials (and their
|
|
612
|
-
// broadcast queues) before init() resolves.
|
|
613
|
-
this.events.onPeerList(msg.peers);
|
|
614
|
-
this.completeCoordinatorHandshake();
|
|
615
|
-
}
|
|
616
|
-
else if (msg.method === "peer_joined") {
|
|
617
|
-
this.events.onPeerJoined(msg.peer);
|
|
618
|
-
}
|
|
619
|
-
else if (msg.method === "become_coordinator") {
|
|
620
|
-
this.events.onBecomeCoordinator(msg.peerList);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
/** Complete connectToCoordinator's handshake after the peer list arrives. */
|
|
624
|
-
completeCoordinatorHandshake() {
|
|
625
|
-
if (this.coordinatorHandshakeTimer !== undefined) {
|
|
626
|
-
clearTimeout(this.coordinatorHandshakeTimer);
|
|
627
|
-
this.coordinatorHandshakeTimer = undefined;
|
|
628
|
-
}
|
|
629
|
-
this.resolveCoordinatorHandshake?.();
|
|
630
|
-
this.resolveCoordinatorHandshake = undefined;
|
|
631
|
-
}
|
|
632
|
-
// -----------------------------------------------------------------------
|
|
633
|
-
// Internal — Data message dispatch
|
|
634
|
-
// -----------------------------------------------------------------------
|
|
635
|
-
dispatchDataMessage(handle, msg) {
|
|
636
|
-
if (this.shutDown)
|
|
637
|
-
return;
|
|
638
|
-
if (msg.method === "become_coordinator") {
|
|
639
|
-
this.events.onBecomeCoordinator(msg.peerList);
|
|
640
|
-
}
|
|
641
|
-
else {
|
|
642
|
-
this.events.onMessage(handle, msg);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
// -----------------------------------------------------------------------
|
|
646
|
-
// Internal — Coordinator server connection handling
|
|
647
|
-
// -----------------------------------------------------------------------
|
|
648
|
-
/**
|
|
649
|
-
* Accepts a new connection on a coordinator server. Reads
|
|
650
|
-
* introduce messages and fires onIntroduction so MeshStore can
|
|
651
|
-
* respond with the peer list and broadcast the arrival.
|
|
652
|
-
* The connection is tagged with the listener's policy.
|
|
653
|
-
*/
|
|
654
|
-
handleCoordinatorServerConnection(socket, policy) {
|
|
655
|
-
if (this.shutDown) {
|
|
656
|
-
socket.destroy();
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
this.coordinatorServerSockets.add(socket);
|
|
660
|
-
socket.on("close", () => this.coordinatorServerSockets.delete(socket));
|
|
661
|
-
socket.on("error", () => this.coordinatorServerSockets.delete(socket));
|
|
662
|
-
const buffer = new MessageBuffer();
|
|
663
|
-
attachSocketHandshake(socket, "server", (data) => {
|
|
664
|
-
const items = buffer.append(data.toString());
|
|
665
|
-
for (const item of items) {
|
|
666
|
-
if (!isMeshMessage(item))
|
|
667
|
-
continue;
|
|
668
|
-
if (item.method === "introduce") {
|
|
669
|
-
if (!this.verifyClaimedPeerId(socket, item.peerId))
|
|
670
|
-
continue;
|
|
671
|
-
const handle = { id: item.peerId, policy };
|
|
672
|
-
this.introConnections.set(handle.id, socket);
|
|
673
|
-
this.events.onIntroduction(handle, {
|
|
674
|
-
peerId: item.peerId,
|
|
675
|
-
dataPort: item.dataPort,
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
else if (item.method === "connect_request") {
|
|
679
|
-
const handle = { id: item.peerId, policy };
|
|
680
|
-
this.pendingConnections.set(handle.id, {
|
|
681
|
-
socket,
|
|
682
|
-
peerId: item.peerId,
|
|
683
|
-
dataPort: item.dataPort,
|
|
684
|
-
name: item.name,
|
|
685
|
-
fingerprint: item.fingerprint,
|
|
686
|
-
policy,
|
|
687
|
-
});
|
|
688
|
-
this.events.onConnectionRequest(handle, {
|
|
689
|
-
peerId: item.peerId,
|
|
690
|
-
dataPort: item.dataPort,
|
|
691
|
-
name: item.name,
|
|
692
|
-
fingerprint: item.fingerprint,
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
}, (error) => this.events.onError?.(error));
|
|
697
|
-
}
|
|
698
|
-
// -----------------------------------------------------------------------
|
|
699
|
-
// Internal — Incoming data connection handling
|
|
700
|
-
// -----------------------------------------------------------------------
|
|
701
|
-
handleIncomingDataConnection(socket) {
|
|
702
|
-
if (this.shutDown) {
|
|
703
|
-
socket.destroy();
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
this.dataServerSockets.add(socket);
|
|
707
|
-
socket.on("close", () => this.dataServerSockets.delete(socket));
|
|
708
|
-
const buffer = new MessageBuffer();
|
|
709
|
-
let remotePeerId;
|
|
710
|
-
let disconnected = false;
|
|
711
|
-
attachSocketHandshake(socket, "server", (data) => {
|
|
712
|
-
const items = buffer.append(data.toString());
|
|
713
|
-
for (const item of items) {
|
|
714
|
-
if (isMeshMessage(item)) {
|
|
715
|
-
if (item.method === "pong") {
|
|
716
|
-
const peerId = item.peerId;
|
|
717
|
-
if (!this.verifyClaimedPeerId(socket, peerId))
|
|
718
|
-
continue;
|
|
719
|
-
remotePeerId = peerId;
|
|
720
|
-
if (!this.peerConnections.has(peerId)) {
|
|
721
|
-
this.peerConnections.set(peerId, { socket, buffer });
|
|
722
|
-
}
|
|
723
|
-
void this.flushPending(peerId, socket);
|
|
724
|
-
const handle = { id: peerId };
|
|
725
|
-
const info = {
|
|
726
|
-
id: peerId,
|
|
727
|
-
port: 0,
|
|
728
|
-
startedAt: new Date().toISOString(),
|
|
729
|
-
};
|
|
730
|
-
this.events.onPeerConnected(handle, info);
|
|
731
|
-
}
|
|
732
|
-
else if (remotePeerId !== undefined) {
|
|
733
|
-
const handle = { id: remotePeerId };
|
|
734
|
-
this.dispatchDataMessage(handle, item);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}, (error) => this.events.onError?.(error));
|
|
739
|
-
const onDisconnect = () => {
|
|
740
|
-
if (disconnected)
|
|
741
|
-
return;
|
|
742
|
-
disconnected = true;
|
|
743
|
-
if (remotePeerId !== undefined) {
|
|
744
|
-
// Only remove peerConnections' entry if it still points at THIS socket -- an outbound dial to the same peer ID can register its own, separate connection there, and this accepted socket closing must never delete that live entry.
|
|
745
|
-
const current = this.peerConnections.get(remotePeerId);
|
|
746
|
-
if (current?.socket === socket) {
|
|
747
|
-
this.peerConnections.delete(remotePeerId);
|
|
748
|
-
}
|
|
749
|
-
if (!this.shutDown) {
|
|
750
|
-
this.events.onPeerDisconnected({ id: remotePeerId });
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
};
|
|
754
|
-
socket.on("close", onDisconnect);
|
|
755
|
-
socket.on("error", onDisconnect);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
//# sourceMappingURL=tls-transport.js.map
|