@aria-cli/wireguard 1.0.37 → 1.0.38

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.
@@ -1,486 +0,0 @@
1
- "use strict";
2
- /**
3
- * PeerDiscoveryService — periodic peer discovery via coordination server.
4
- *
5
- * Polls the coordination server every 60s, diffs against local PeerRegistry,
6
- * refreshes remote endpoints for known peers, and auto-starts/stops tunnels for
7
- * new/disappeared peers. Emits events for peer join/leave so higher-level systems
8
- * can react.
9
- *
10
- * Architecture:
11
- * PeerDiscoveryService → POST /api/v1/network/peers → diff local →
12
- * applyPeerRepair/startTunnel/stopTunnel
13
- * STUN refreshes OUR endpoint. Peer discovery refreshes REMOTE peer endpoints.
14
- *
15
- * Limitation: The coordination server is a peer's ARIA HTTP server, NOT an
16
- * independent highly-available service. If the coordination server peer goes
17
- * offline, discovery stops for the entire mesh. For production meshes (>10 peers),
18
- * deploy a dedicated coordination service at the coordinationUrl.
19
- *
20
- * Discovery only auto-connects peers that have completed the invite flow (PSK
21
- * exchange). Unknown peers emit "peerDiscoveredUnknown" for the daemon to handle.
22
- */
23
- Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.PeerDiscoveryService = void 0;
25
- const node_events_1 = require("node:events");
26
- const tools_1 = require("@aria-cli/tools");
27
- const bootstrap_tls_js_1 = require("./bootstrap-tls.js");
28
- /**
29
- * Periodic peer discovery service.
30
- *
31
- * Polls coordination server, diffs against local state, auto-manages tunnels.
32
- * Emits: "peerJoined" (displayNameSnapshot|nodeId, nodeId), "peerLeft" (displayNameSnapshot|nodeId, nodeId), "error" (Error)
33
- */
34
- class PeerDiscoveryService extends node_events_1.EventEmitter {
35
- interval = null;
36
- networkManager;
37
- nodeId;
38
- coordinationUrl;
39
- displayNameSnapshot;
40
- signal;
41
- pollIntervalMs;
42
- maxPeers;
43
- signingPublicKey;
44
- signingPrivateKey;
45
- envelopeSigner;
46
- coordinationCaCert;
47
- coordinationTlsIdentity;
48
- getLocalRegistrationState;
49
- /** Track known remote peers by durable peer principal for diff */
50
- knownRemotePeers = new Map();
51
- pollInFlight = null;
52
- initialSyncPromise;
53
- resolveInitialSync = null;
54
- initialSyncSettled = false;
55
- stopped = false;
56
- constructor(options) {
57
- super();
58
- this.networkManager = options.networkManager;
59
- this.nodeId = options.nodeId;
60
- this.coordinationUrl = options.coordinationUrl.replace(/\/$/, "");
61
- this.displayNameSnapshot = options.displayNameSnapshot;
62
- this.signal = options.signal;
63
- this.pollIntervalMs = options.pollIntervalMs ?? 60_000;
64
- this.maxPeers = options.maxPeers ?? 128;
65
- this.signingPublicKey = options.signingPublicKey;
66
- this.signingPrivateKey = options.signingPrivateKey;
67
- this.envelopeSigner = options.envelopeSigner;
68
- this.coordinationCaCert = options.coordinationCaCert?.trim() || undefined;
69
- this.coordinationTlsIdentity = options.coordinationTlsIdentity?.trim() || undefined;
70
- this.getLocalRegistrationState = options.getLocalRegistrationState;
71
- if (!this.envelopeSigner) {
72
- throw new Error("Peer discovery requires an envelope signer");
73
- }
74
- this.initialSyncPromise = new Promise((resolve) => {
75
- this.resolveInitialSync = () => {
76
- if (this.initialSyncSettled)
77
- return;
78
- this.initialSyncSettled = true;
79
- resolve();
80
- };
81
- });
82
- if (!this.signingPublicKey || !this.signingPrivateKey) {
83
- throw new Error("Peer discovery requires Ed25519 signing keys (signingPublicKey + signingPrivateKey)");
84
- }
85
- if ((this.coordinationCaCert && !this.coordinationTlsIdentity) ||
86
- (!this.coordinationCaCert && this.coordinationTlsIdentity)) {
87
- throw new Error("Peer discovery HTTPS pinning requires both coordinationCaCert and coordinationTlsIdentity");
88
- }
89
- // Wire abort signal for clean shutdown
90
- if (this.signal) {
91
- this.signal.addEventListener("abort", () => this.stop(), { once: true });
92
- }
93
- }
94
- /** Start periodic peer discovery */
95
- start() {
96
- if (this.interval || this.stopped)
97
- return;
98
- // Initial discovery + heartbeat
99
- this.schedulePoll();
100
- this.interval = setInterval(() => {
101
- if (this.stopped || this.signal?.aborted) {
102
- this.stop();
103
- return;
104
- }
105
- this.schedulePoll();
106
- }, this.pollIntervalMs);
107
- }
108
- /** Resolve once the first discovery poll attempt finishes (success or failure). */
109
- waitForInitialSync() {
110
- return this.initialSyncPromise;
111
- }
112
- /** Stop periodic discovery */
113
- stop() {
114
- this.stopped = true;
115
- if (this.interval) {
116
- clearInterval(this.interval);
117
- this.interval = null;
118
- }
119
- this.resolveInitialSync?.();
120
- }
121
- schedulePoll() {
122
- if (this.pollInFlight)
123
- return;
124
- this.pollInFlight = this.poll()
125
- .catch((err) => this.reportError(err instanceof Error ? err : new Error(String(err))))
126
- .finally(() => {
127
- this.pollInFlight = null;
128
- this.resolveInitialSync?.();
129
- });
130
- }
131
- reportError(err) {
132
- if (this.listenerCount("error") > 0) {
133
- this.emit("error", err);
134
- }
135
- }
136
- peerEndpointRevision(peer) {
137
- if (typeof peer.endpointRevision === "number" && Number.isInteger(peer.endpointRevision)) {
138
- return peer.endpointRevision;
139
- }
140
- return null;
141
- }
142
- peerFreshness(peer) {
143
- const candidates = [
144
- typeof peer.updatedAt === "number" && Number.isFinite(peer.updatedAt) ? peer.updatedAt : null,
145
- typeof peer.lastHandshake === "number" && Number.isFinite(peer.lastHandshake)
146
- ? peer.lastHandshake
147
- : null,
148
- typeof peer.createdAt === "number" && Number.isFinite(peer.createdAt) ? peer.createdAt : null,
149
- ].filter((value) => value !== null);
150
- return candidates.length > 0 ? Math.max(...candidates) : null;
151
- }
152
- classifyRepairOutcome(peer) {
153
- const repairInput = this.repairInputForPeer(peer);
154
- if (repairInput === null) {
155
- return "ok";
156
- }
157
- if (repairInput === false) {
158
- return "blocked";
159
- }
160
- const repair = this.networkManager.applyPeerRepair(repairInput);
161
- if (repair.repaired) {
162
- return "ok";
163
- }
164
- if ("errorCode" in repair && repair.errorCode === "not_found") {
165
- return "unknown";
166
- }
167
- return "blocked";
168
- }
169
- repairInputForPeer(peer) {
170
- if (peer.endpointHost === null && peer.endpointPort === null) {
171
- return null;
172
- }
173
- if (typeof peer.endpointHost !== "string" ||
174
- typeof peer.endpointPort !== "number" ||
175
- !Number.isInteger(peer.endpointPort) ||
176
- peer.endpointPort < 1 ||
177
- peer.endpointPort > 65_535 ||
178
- typeof peer.endpointRevision !== "number" ||
179
- !Number.isInteger(peer.endpointRevision) ||
180
- peer.endpointRevision < 0) {
181
- return false;
182
- }
183
- return {
184
- nodeId: peer.nodeId,
185
- endpointHost: peer.endpointHost,
186
- endpointPort: peer.endpointPort,
187
- endpointRevision: peer.endpointRevision,
188
- };
189
- }
190
- async postControlPlane(path, payload) {
191
- const url = `${this.coordinationUrl}${path}`;
192
- const body = JSON.stringify(payload);
193
- if (url.startsWith("https://") && this.coordinationCaCert && this.coordinationTlsIdentity) {
194
- return (0, bootstrap_tls_js_1.bootstrapTlsRequest)(url, {
195
- method: "POST",
196
- body,
197
- caCert: this.coordinationCaCert,
198
- expectedTlsIdentity: this.coordinationTlsIdentity,
199
- timeoutMs: 10_000,
200
- headers: {
201
- "Content-Type": "application/json",
202
- "Content-Length": Buffer.byteLength(body).toString(),
203
- },
204
- });
205
- }
206
- const response = await fetch(url, {
207
- method: "POST",
208
- headers: { "Content-Type": "application/json" },
209
- body,
210
- signal: AbortSignal.timeout(10_000),
211
- });
212
- let responseBody = "";
213
- if (typeof response.text === "function") {
214
- responseBody = await response.text();
215
- }
216
- else if (typeof response.json === "function") {
217
- responseBody = JSON.stringify(await response.json());
218
- }
219
- return {
220
- status: response.status,
221
- body: responseBody,
222
- };
223
- }
224
- /** Force an immediate heartbeat to the coordination server */
225
- async heartbeat() {
226
- const localRegistration = this.getLocalRegistrationState?.() ?? undefined;
227
- const body = {
228
- nodeId: this.nodeId,
229
- displayNameSnapshot: this.displayNameSnapshot,
230
- };
231
- const envelopePayload = {
232
- nodeId: this.nodeId,
233
- displayNameSnapshot: this.displayNameSnapshot,
234
- };
235
- if (localRegistration?.endpointHost &&
236
- typeof localRegistration.endpointPort === "number" &&
237
- Number.isInteger(localRegistration.endpointPort)) {
238
- body.endpointHost = localRegistration.endpointHost;
239
- body.endpointPort = localRegistration.endpointPort;
240
- envelopePayload.endpointHost = localRegistration.endpointHost;
241
- envelopePayload.endpointPort = localRegistration.endpointPort;
242
- body.endpointRevision = localRegistration.endpointRevision ?? 0;
243
- envelopePayload.endpointRevision = localRegistration.endpointRevision ?? 0;
244
- }
245
- body.envelope = this.envelopeSigner("network.register", envelopePayload);
246
- const response = await this.postControlPlane("/api/v1/network/register", body);
247
- if (response.status < 200 || response.status >= 300) {
248
- throw new Error(`Peer discovery heartbeat failed: HTTP ${response.status}`);
249
- }
250
- }
251
- parseDiscoveredPeer(value) {
252
- if (!value || typeof value !== "object")
253
- return null;
254
- const peer = value;
255
- const parsedNodeId = tools_1.NodeIdSchema.safeParse(peer.nodeId);
256
- const parsedStatus = tools_1.LegacyPeerRegistryStatusSchema.safeParse(peer.status);
257
- const transportPublicKey = typeof peer.transportPublicKey === "string" && peer.transportPublicKey.length > 0
258
- ? tools_1.PeerTransportIdSchema.parse(peer.transportPublicKey)
259
- : null;
260
- const displayNameSnapshot = typeof peer.displayNameSnapshot === "string" ? peer.displayNameSnapshot : undefined;
261
- const endpointHost = peer.endpointHost === null ? null : peer.endpointHost;
262
- const endpointPort = peer.endpointPort === null ? null : peer.endpointPort;
263
- const endpointRevision = typeof peer.endpointRevision === "number" ? peer.endpointRevision : undefined;
264
- const updatedAt = typeof peer.updatedAt === "number" ? peer.updatedAt : undefined;
265
- const lastHandshake = peer.lastHandshake === null || typeof peer.lastHandshake === "number"
266
- ? peer.lastHandshake
267
- : undefined;
268
- const createdAt = typeof peer.createdAt === "number" ? peer.createdAt : undefined;
269
- const displayNameOk = displayNameSnapshot === undefined || displayNameSnapshot.length > 0;
270
- const endpointHostOk = endpointHost === null ||
271
- (typeof endpointHost === "string" &&
272
- endpointHost.length > 0 &&
273
- endpointHost.length <= 253 &&
274
- /^[a-zA-Z0-9._:-]+$/.test(endpointHost));
275
- const endpointPortOk = endpointPort === null ||
276
- (typeof endpointPort === "number" &&
277
- Number.isInteger(endpointPort) &&
278
- endpointPort >= 1 &&
279
- endpointPort <= 65535);
280
- const endpointRevisionRequired = endpointHost !== null &&
281
- endpointPort !== null &&
282
- parsedStatus.success &&
283
- parsedStatus.data !== "revoked";
284
- const endpointRevisionOk = (!endpointRevisionRequired && endpointRevision === undefined) ||
285
- (typeof endpointRevision === "number" &&
286
- Number.isInteger(endpointRevision) &&
287
- endpointRevision >= 0);
288
- const endpointMutationComplete = (endpointHost === null && endpointPort === null && endpointRevision === undefined) ||
289
- (typeof endpointHost === "string" &&
290
- typeof endpointPort === "number" &&
291
- typeof endpointRevision === "number");
292
- const lastHandshakeOk = peer.lastHandshake === undefined ||
293
- lastHandshake === null ||
294
- (typeof lastHandshake === "number" && Number.isFinite(lastHandshake));
295
- const updatedAtOk = peer.updatedAt === undefined || (typeof updatedAt === "number" && Number.isFinite(updatedAt));
296
- const createdAtOk = peer.createdAt === undefined || (typeof createdAt === "number" && Number.isFinite(createdAt));
297
- if (!parsedNodeId.success ||
298
- !transportPublicKey ||
299
- !parsedStatus.success ||
300
- !displayNameOk ||
301
- !endpointHostOk ||
302
- !endpointPortOk ||
303
- !endpointRevisionOk ||
304
- !updatedAtOk ||
305
- !endpointMutationComplete ||
306
- !lastHandshakeOk ||
307
- !createdAtOk) {
308
- return null;
309
- }
310
- return {
311
- nodeId: parsedNodeId.data,
312
- transportPublicKey,
313
- ...(displayNameSnapshot ? { displayNameSnapshot } : {}),
314
- status: parsedStatus.data,
315
- endpointHost,
316
- endpointPort,
317
- ...(endpointRevision !== undefined ? { endpointRevision } : {}),
318
- ...(updatedAt !== undefined ? { updatedAt } : {}),
319
- ...(lastHandshake !== undefined ? { lastHandshake } : {}),
320
- ...(createdAt !== undefined ? { createdAt } : {}),
321
- };
322
- }
323
- /** Single poll cycle: heartbeat + discover + diff + act */
324
- async poll() {
325
- if (this.stopped || this.signal?.aborted)
326
- return;
327
- // 1. Send heartbeat (register ourselves)
328
- await this.heartbeat();
329
- // 2. Fetch peer list from coordination server
330
- const remotePeers = await this.fetchPeers();
331
- if (!remotePeers)
332
- return;
333
- // 3. Diff and act
334
- await this.reconcile(remotePeers);
335
- }
336
- /** Fetch peers from coordination server using envelope-authenticated POST only. */
337
- async fetchPeers() {
338
- const envelope = this.envelopeSigner("network.list_peers", {
339
- nodeId: this.nodeId,
340
- displayNameSnapshot: this.displayNameSnapshot,
341
- });
342
- const response = await this.postControlPlane("/api/v1/network/peers", { envelope });
343
- if (response.status < 200 || response.status >= 300) {
344
- throw new Error(`Peer discovery fetch failed: HTTP ${response.status}`);
345
- }
346
- const data = JSON.parse(response.body);
347
- if (!Array.isArray(data.peers)) {
348
- throw new Error("Peer discovery fetch returned invalid payload: peers must be an array");
349
- }
350
- const MAX_REMOTE_PEERS = 5000;
351
- if (data.peers.length > MAX_REMOTE_PEERS) {
352
- throw new Error(`Peer discovery fetch returned too many peers (${data.peers.length})`);
353
- }
354
- const parsedPeers = [];
355
- for (const peer of data.peers) {
356
- const parsedPeer = this.parseDiscoveredPeer(peer);
357
- if (!parsedPeer) {
358
- throw new Error("Peer discovery fetch returned malformed peer record");
359
- }
360
- parsedPeers.push(parsedPeer);
361
- }
362
- return parsedPeers;
363
- }
364
- /** Reconcile remote peers with local state */
365
- async reconcile(remotePeers) {
366
- const myNodeId = this.nodeId;
367
- const remoteMap = new Map();
368
- // Build map of remote active peers (excluding self and revoked)
369
- for (const peer of remotePeers) {
370
- if (peer.nodeId === myNodeId)
371
- continue; // Skip self
372
- const { identityState } = (0, tools_1.derivePeerStateFromLegacyStatus)(peer);
373
- if (!(0, tools_1.canRefreshEndpoint)(identityState))
374
- continue;
375
- remoteMap.set(peer.nodeId, peer);
376
- }
377
- // Detect known peers whose endpoint changed on the coordination server.
378
- // Reboots/NAT rebinding can invalidate the persisted endpoint even when the
379
- // peer identity is unchanged. Refresh the live tunnel first, then persist.
380
- for (const [nodeId, peer] of remoteMap) {
381
- const known = this.knownRemotePeers.get(nodeId);
382
- if (!known)
383
- continue;
384
- const endpointChanged = known.endpointHost !== peer.endpointHost || known.endpointPort !== peer.endpointPort;
385
- const endpointRevisionChanged = (known.endpointRevision ?? 0) !== (peer.endpointRevision ?? 0);
386
- const transportChanged = known.transportPublicKey !== peer.transportPublicKey;
387
- const knownEndpointRevision = this.peerEndpointRevision(known);
388
- const remoteEndpointRevision = this.peerEndpointRevision(peer);
389
- const knownFreshness = this.peerFreshness(known);
390
- const remoteFreshness = this.peerFreshness(peer);
391
- const staleEndpointRegression = endpointChanged &&
392
- knownEndpointRevision !== null &&
393
- remoteEndpointRevision !== null &&
394
- remoteEndpointRevision <= knownEndpointRevision;
395
- const staleEndpointFallback = endpointChanged &&
396
- knownEndpointRevision === null &&
397
- remoteEndpointRevision === null &&
398
- knownFreshness !== null &&
399
- remoteFreshness !== null &&
400
- remoteFreshness <= knownFreshness;
401
- const staleTransportRevision = transportChanged &&
402
- knownEndpointRevision !== null &&
403
- remoteEndpointRevision !== null &&
404
- remoteEndpointRevision <= knownEndpointRevision;
405
- const staleTransportRegression = transportChanged &&
406
- knownFreshness !== null &&
407
- remoteFreshness !== null &&
408
- remoteFreshness <= knownFreshness;
409
- if (staleEndpointRegression ||
410
- staleEndpointFallback ||
411
- staleTransportRevision ||
412
- staleTransportRegression) {
413
- continue;
414
- }
415
- if (endpointChanged && peer.endpointHost && peer.endpointPort) {
416
- const repairOutcome = this.classifyRepairOutcome(peer);
417
- if (repairOutcome === "unknown") {
418
- this.reportError(new Error(`Peer discovery lost authoritative repair binding for ${peer.displayNameSnapshot ?? peer.nodeId} (${peer.nodeId})`));
419
- continue;
420
- }
421
- if (repairOutcome === "blocked") {
422
- continue;
423
- }
424
- }
425
- // Continuity rebinding can rotate transport identity while the durable
426
- // node identity stays stable, but discovery is read-only and may not
427
- // rebind transport identity. Only the explicit continuity transaction may
428
- // authorize a replacement transport key for an existing node binding.
429
- if (transportChanged) {
430
- this.reportError(new Error(`Peer discovery observed transport rotation for ${peer.displayNameSnapshot ?? peer.nodeId} (${peer.nodeId}); explicit continuity required`));
431
- continue;
432
- }
433
- if (endpointRevisionChanged) {
434
- this.knownRemotePeers.set(nodeId, peer);
435
- continue;
436
- }
437
- this.knownRemotePeers.set(nodeId, peer);
438
- }
439
- // Detect new peers (in remote, not in local known set)
440
- for (const [nodeId, peer] of remoteMap) {
441
- if (this.knownRemotePeers.has(nodeId))
442
- continue;
443
- // Check tunnel limit
444
- if (this.networkManager.activeTunnelCount >= this.maxPeers)
445
- break;
446
- try {
447
- const repairOutcome = this.classifyRepairOutcome(peer);
448
- if (repairOutcome === "unknown") {
449
- this.emit("peerDiscoveredUnknown", peer.displayNameSnapshot ?? peer.nodeId, peer.nodeId, peer);
450
- continue;
451
- }
452
- if (repairOutcome === "blocked") {
453
- continue;
454
- }
455
- await this.networkManager.startTunnel(peer.nodeId);
456
- this.knownRemotePeers.set(nodeId, peer);
457
- this.emit("peerJoined", peer.displayNameSnapshot ?? peer.nodeId, peer.nodeId);
458
- }
459
- catch (err) {
460
- // Peer exists on coordination server but not in local registry —
461
- // they haven't gone through the invite flow with us.
462
- // Emit distinct event so the daemon can decide whether to auto-invite.
463
- const message = err instanceof Error ? err.message : String(err);
464
- const code = err?.code;
465
- if (code === "PEER_NOT_IN_REGISTRY" || message.includes("not found in registry")) {
466
- this.emit("peerDiscoveredUnknown", peer.displayNameSnapshot ?? peer.nodeId, peer.nodeId, peer);
467
- }
468
- else {
469
- this.reportError(new Error(`Peer discovery failed to start tunnel for ${peer.displayNameSnapshot ?? peer.nodeId} (${peer.nodeId}): ${message}`));
470
- }
471
- }
472
- }
473
- // Detect disappeared/revoked peers (in local known set, not in remote active set)
474
- for (const [nodeId, peer] of this.knownRemotePeers) {
475
- if (remoteMap.has(nodeId))
476
- continue;
477
- // Peer disappeared or was revoked
478
- this.networkManager.stopTunnel(nodeId);
479
- this.knownRemotePeers.delete(nodeId);
480
- this.emit("peerLeft", peer.displayNameSnapshot ?? peer.nodeId, peer.nodeId);
481
- }
482
- // Keep knownRemotePeers as connected peers only.
483
- }
484
- }
485
- exports.PeerDiscoveryService = PeerDiscoveryService;
486
- //# sourceMappingURL=peer-discovery.js.map