@aria-cli/wireguard 1.0.38 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3391 @@
1
+ "use strict";
2
+ /**
3
+ * Network manager — auto-setup, peer management, and invite flow for ARIA.
4
+ *
5
+ * Creates a secure WireGuard network on first run with zero user configuration.
6
+ * Manages peer lifecycle (invite, revoke, list) through the manage_network tool.
7
+ * Persists peer registry in Memoria DB (network_peers table).
8
+ *
9
+ * Architecture:
10
+ * NetworkManager owns a SecureTunnel per peer. On startup it loads peers from
11
+ * the DB, creates tunnels, and starts them. New peers join via invite tokens
12
+ * that encode the leader's public key + endpoint + PSK.
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.NetworkManager = exports.PeerRegistry = void 0;
49
+ exports.generateSigningKeypair = generateSigningKeypair;
50
+ exports.generateKeyPair = generateKeyPair;
51
+ exports.createInviteToken = createInviteToken;
52
+ exports.decodeInviteToken = decodeInviteToken;
53
+ exports.ensureSecureNetwork = ensureSecureNetwork;
54
+ const crypto = __importStar(require("node:crypto"));
55
+ const fs = __importStar(require("node:fs"));
56
+ const path = __importStar(require("node:path"));
57
+ const tools_1 = require("@aria-cli/tools");
58
+ const bootstrap_authority_js_1 = require("./bootstrap-authority.js");
59
+ const bootstrap_tls_js_1 = require("./bootstrap-tls.js");
60
+ const route_ownership_js_1 = require("./route-ownership.js");
61
+ const tunnel_js_1 = require("./tunnel.js");
62
+ const resilient_tunnel_js_1 = require("./resilient-tunnel.js");
63
+ const DEFAULT_WIREGUARD_LISTEN_PORT = 58291;
64
+ const WIREGUARD_TEST_LISTEN_PORT_ENV = "ARIA_WIREGUARD_TEST_LISTEN_PORT";
65
+ function resolveDefaultWireguardListenPort() {
66
+ const override = process.env[WIREGUARD_TEST_LISTEN_PORT_ENV]?.trim();
67
+ if (!override) {
68
+ return DEFAULT_WIREGUARD_LISTEN_PORT;
69
+ }
70
+ const parsed = Number(override);
71
+ if (Number.isInteger(parsed) && parsed >= 0 && parsed <= 65_535) {
72
+ return parsed;
73
+ }
74
+ return DEFAULT_WIREGUARD_LISTEN_PORT;
75
+ }
76
+ /** Write to stderr only when ARIA_LOG_LEVEL=debug. Tunnel state noise is invisible by default. */
77
+ function wireguardDebug(msg) {
78
+ if (typeof process !== "undefined" && process.env.ARIA_LOG_LEVEL === "debug" && process.stderr) {
79
+ process.stderr.write(msg);
80
+ }
81
+ }
82
+ function appendWireguardDiagnostic(ariaDir, entry) {
83
+ try {
84
+ const logDir = path.join(ariaDir, "audit");
85
+ fs.mkdirSync(logDir, { recursive: true });
86
+ fs.appendFileSync(path.join(logDir, "wireguard-diagnostics.jsonl"), JSON.stringify(entry) + "\n");
87
+ }
88
+ catch {
89
+ // Diagnostics are best-effort only.
90
+ }
91
+ }
92
+ const PEER_INFO_SELECT = `SELECT public_key AS publicKey, node_id AS nodeId, name, endpoint_host AS endpointHost,
93
+ endpoint_port AS endpointPort, endpoint_revision AS endpointRevision,
94
+ control_endpoint_host AS controlEndpointHost,
95
+ control_endpoint_port AS controlEndpointPort,
96
+ control_tls_ca_fingerprint AS controlTlsCaFingerprint,
97
+ status, last_handshake AS lastHandshake,
98
+ created_at AS createdAt, updated_at AS updatedAt, signing_public_key AS signingPublicKey
99
+ FROM network_peers`;
100
+ function normalizeEndpointRevision(value) {
101
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
102
+ }
103
+ function deriveLocalDisplayNameSnapshot(arionName) {
104
+ const normalized = arionName
105
+ .trim()
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9]+/g, "-")
108
+ .replace(/^-+|-+$/g, "");
109
+ if (normalized.length === 0) {
110
+ return "node";
111
+ }
112
+ return normalized.endsWith("-node") ? normalized : `${normalized}-node`;
113
+ }
114
+ const PENDING_INVITE_PLACEHOLDER_PREFIX = "pending-invite:";
115
+ function normalizeInviteLabel(inviteLabel) {
116
+ if (inviteLabel === undefined) {
117
+ return undefined;
118
+ }
119
+ const trimmed = inviteLabel?.trim();
120
+ if (trimmed.length === 0) {
121
+ throw new Error("Invite peer name must not be empty");
122
+ }
123
+ return trimmed;
124
+ }
125
+ function buildPendingInvitePlaceholderName(tokenNonce) {
126
+ return `${PENDING_INVITE_PLACEHOLDER_PREFIX}${tokenNonce}`;
127
+ }
128
+ function extractPendingInviteLabel(name) {
129
+ return name.startsWith(PENDING_INVITE_PLACEHOLDER_PREFIX) ? undefined : name;
130
+ }
131
+ function matchesLegacyDerivedLocalPeerName(candidate, arionName) {
132
+ const trimmedArionName = arionName.trim();
133
+ if (trimmedArionName.length === 0) {
134
+ return false;
135
+ }
136
+ const escapedArionName = trimmedArionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ return new RegExp(`^${escapedArionName}-[0-9a-f]{8}$`, "i").test(candidate.trim());
138
+ }
139
+ function signingKeyFingerprintFromPublicKey(signingPublicKey) {
140
+ return tools_1.PrincipalFingerprintSchema.parse(crypto.createHash("sha256").update(Buffer.from(signingPublicKey, "base64")).digest("hex"));
141
+ }
142
+ function trySigningKeyFingerprintFromPublicKey(signingPublicKey) {
143
+ try {
144
+ assertValidSigningPublicKeyB64(signingPublicKey, "Peer signing key");
145
+ return signingKeyFingerprintFromPublicKey(signingPublicKey);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ function hasLegacyRemoteActorKey(record) {
152
+ const legacyRemoteActorKey = "peer" + "Id";
153
+ return Object.prototype.hasOwnProperty.call(record, legacyRemoteActorKey);
154
+ }
155
+ function buildPeerInfo(row) {
156
+ const hasControlEndpoint = row.controlEndpointHost !== null ||
157
+ row.controlEndpointPort !== null ||
158
+ row.controlTlsCaFingerprint !== null;
159
+ const brandedCaFingerprint = row.controlTlsCaFingerprint !== null
160
+ ? tools_1.TlsCaFingerprintSchema.parse(row.controlTlsCaFingerprint)
161
+ : null;
162
+ const derivedControlTlsIdentity = typeof row.signingPublicKey === "string" && row.signingPublicKey.length > 0
163
+ ? trySigningKeyFingerprintFromPublicKey(row.signingPublicKey)
164
+ : null;
165
+ return {
166
+ ...row,
167
+ endpointRevision: normalizeEndpointRevision(row.endpointRevision),
168
+ nodeId: row.nodeId,
169
+ updatedAt: row.updatedAt ?? row.createdAt,
170
+ controlTlsCaFingerprint: brandedCaFingerprint,
171
+ controlEndpoint: hasControlEndpoint
172
+ ? {
173
+ host: row.controlEndpointHost,
174
+ port: row.controlEndpointPort,
175
+ tlsCaFingerprint: brandedCaFingerprint,
176
+ tlsServerIdentity: derivedControlTlsIdentity,
177
+ protocolVersion: 1,
178
+ endpointRevision: normalizeEndpointRevision(row.endpointRevision),
179
+ }
180
+ : null,
181
+ };
182
+ }
183
+ function parseOptionalNodeId(value) {
184
+ return typeof value === "string" && value.trim().length > 0 ? tools_1.NodeIdSchema.parse(value) : null;
185
+ }
186
+ function parsePeerInfoRow(row) {
187
+ return {
188
+ publicKey: tools_1.PeerTransportIdSchema.parse(row.publicKey),
189
+ nodeId: parseOptionalNodeId(row.nodeId),
190
+ name: row.name,
191
+ endpointHost: row.endpointHost ?? null,
192
+ endpointPort: row.endpointPort ?? null,
193
+ endpointRevision: row.endpointRevision ?? null,
194
+ controlEndpointHost: row.controlEndpointHost ?? null,
195
+ controlEndpointPort: row.controlEndpointPort ?? null,
196
+ controlTlsCaFingerprint: row.controlTlsCaFingerprint ?? null,
197
+ status: row.status,
198
+ lastHandshake: row.lastHandshake ?? null,
199
+ createdAt: row.createdAt,
200
+ updatedAt: row.updatedAt ?? null,
201
+ signingPublicKey: row.signingPublicKey ?? null,
202
+ };
203
+ }
204
+ function assertNoUnexpectedActorKeys(input, allowedKeys, context) {
205
+ const unexpectedKeys = Object.keys(input).filter((key) => !allowedKeys.includes(key));
206
+ if (unexpectedKeys.length > 0) {
207
+ throw new Error(`${context} received unsupported fields: ${unexpectedKeys.join(", ")}`);
208
+ }
209
+ }
210
+ function hasAdvertisedEndpointChange(previous, next) {
211
+ if (!previous) {
212
+ return (next.endpointHost !== undefined ||
213
+ next.endpointPort !== undefined ||
214
+ next.controlEndpointHost !== undefined ||
215
+ next.controlEndpointPort !== undefined ||
216
+ next.controlTlsCaFingerprint !== undefined);
217
+ }
218
+ return ((next.endpointHost !== undefined && next.endpointHost !== previous.endpointHost) ||
219
+ (next.endpointPort !== undefined && next.endpointPort !== previous.endpointPort) ||
220
+ (next.controlEndpointHost !== undefined &&
221
+ next.controlEndpointHost !== previous.controlEndpointHost) ||
222
+ (next.controlEndpointPort !== undefined &&
223
+ next.controlEndpointPort !== previous.controlEndpointPort) ||
224
+ (next.controlTlsCaFingerprint !== undefined &&
225
+ next.controlTlsCaFingerprint !== previous.controlTlsCaFingerprint));
226
+ }
227
+ function resolveAdvertisedEndpointState(previous, next) {
228
+ const storedEndpointRevision = normalizeEndpointRevision(previous?.endpointRevision);
229
+ const hasStoredAdvertisedEndpoint = previous !== null &&
230
+ (previous.endpointHost !== null ||
231
+ previous.endpointPort !== null ||
232
+ previous.controlEndpointHost !== null ||
233
+ previous.controlEndpointPort !== null ||
234
+ previous.controlTlsCaFingerprint !== null);
235
+ const endpointChanged = hasAdvertisedEndpointChange(previous, next);
236
+ const hasExplicitEndpointRevision = typeof next.endpointRevision === "number" &&
237
+ Number.isInteger(next.endpointRevision) &&
238
+ next.endpointRevision >= 0;
239
+ const requestedEndpointRevision = hasExplicitEndpointRevision && typeof next.endpointRevision === "number"
240
+ ? next.endpointRevision
241
+ : endpointChanged
242
+ ? storedEndpointRevision + 1
243
+ : storedEndpointRevision;
244
+ const baselineEndpointAdvertisement = previous !== null &&
245
+ !hasStoredAdvertisedEndpoint &&
246
+ endpointChanged &&
247
+ requestedEndpointRevision === storedEndpointRevision;
248
+ const staleRevision = previous !== null &&
249
+ !baselineEndpointAdvertisement &&
250
+ (requestedEndpointRevision < storedEndpointRevision ||
251
+ (endpointChanged && requestedEndpointRevision === storedEndpointRevision));
252
+ const commitEndpointMutation = previous === null
253
+ ? endpointChanged || requestedEndpointRevision > 0
254
+ : baselineEndpointAdvertisement ||
255
+ (!staleRevision && requestedEndpointRevision > storedEndpointRevision);
256
+ return {
257
+ endpointHost: commitEndpointMutation
258
+ ? (next.endpointHost ?? previous?.endpointHost ?? null)
259
+ : (previous?.endpointHost ?? null),
260
+ endpointPort: commitEndpointMutation
261
+ ? (next.endpointPort ?? previous?.endpointPort ?? null)
262
+ : (previous?.endpointPort ?? null),
263
+ endpointRevision: previous !== null && staleRevision ? storedEndpointRevision : requestedEndpointRevision,
264
+ controlEndpointHost: commitEndpointMutation
265
+ ? (next.controlEndpointHost ?? previous?.controlEndpointHost ?? null)
266
+ : (previous?.controlEndpointHost ?? null),
267
+ controlEndpointPort: commitEndpointMutation
268
+ ? (next.controlEndpointPort ?? previous?.controlEndpointPort ?? null)
269
+ : (previous?.controlEndpointPort ?? null),
270
+ controlTlsCaFingerprint: commitEndpointMutation
271
+ ? (next.controlTlsCaFingerprint ?? previous?.controlTlsCaFingerprint ?? null)
272
+ : (previous?.controlTlsCaFingerprint ?? null),
273
+ };
274
+ }
275
+ /**
276
+ * Peer registry — wraps the network_peers table.
277
+ */
278
+ class PeerRegistry {
279
+ db;
280
+ constructor(db) {
281
+ this.db = db;
282
+ // Schema is managed by Memoria migrations (V31 creates network_peers + token_claims).
283
+ // PeerRegistry does NO schema creation — it only reads/writes data.
284
+ this.reconcileSchema();
285
+ }
286
+ reconcileSchema() {
287
+ const columns = this.db.prepare("PRAGMA table_info(network_peers)").all();
288
+ const columnNames = new Set(columns.map((column) => column.name));
289
+ if (columnNames.has("peer_id")) {
290
+ throw new Error("Legacy network_peers schema requires hard reset: legacy peer_id alias");
291
+ }
292
+ const missingColumns = ["node_id", "endpoint_revision", "updated_at"].filter((column) => !columnNames.has(column));
293
+ if (missingColumns.length > 0) {
294
+ throw new Error(`Legacy network_peers schema requires hard reset: missing columns: ${missingColumns.join(", ")}`);
295
+ }
296
+ const rowsMissingUpdatedAt = this.db
297
+ .prepare(`SELECT public_key AS publicKey
298
+ FROM network_peers
299
+ WHERE updated_at IS NULL`)
300
+ .all();
301
+ if (rowsMissingUpdatedAt.length > 0) {
302
+ throw new Error("Legacy network_peers rows require hard reset: missing updated_at");
303
+ }
304
+ const rowsMissingDurablePeerIds = this.db
305
+ .prepare(`SELECT public_key AS publicKey
306
+ FROM network_peers
307
+ WHERE signing_public_key IS NOT NULL AND (node_id IS NULL OR LENGTH(TRIM(node_id)) = 0)`)
308
+ .all();
309
+ if (rowsMissingDurablePeerIds.length > 0) {
310
+ throw new Error("Legacy network_peers rows require hard reset: missing durable peer ids");
311
+ }
312
+ const duplicateDurableNodeIds = this.db
313
+ .prepare(`SELECT TRIM(node_id) AS nodeId, COUNT(*) AS count
314
+ FROM network_peers
315
+ WHERE node_id IS NOT NULL AND LENGTH(TRIM(node_id)) > 0
316
+ GROUP BY TRIM(node_id)
317
+ HAVING COUNT(*) > 1`)
318
+ .all()
319
+ .map((row) => {
320
+ const nodeId = parseOptionalNodeId(row.nodeId);
321
+ if (!nodeId) {
322
+ throw new Error("Duplicate durable nodeId query returned an invalid nodeId");
323
+ }
324
+ return {
325
+ nodeId,
326
+ count: row.count,
327
+ };
328
+ });
329
+ if (duplicateDurableNodeIds.length > 0) {
330
+ throw new Error(`Legacy network_peers rows require hard reset: duplicate durable node ids: ${duplicateDurableNodeIds
331
+ .map((row) => `${row.nodeId} (${row.count})`)
332
+ .join(", ")}`);
333
+ }
334
+ }
335
+ /** Add or update a peer */
336
+ upsert(peer) {
337
+ if (peer.signingPublicKey) {
338
+ assertValidSigningPublicKeyB64(peer.signingPublicKey, "Peer signing key");
339
+ }
340
+ const existingByPublicKey = this.getByPublicKey(peer.publicKey);
341
+ const explicitNodeId = peer.nodeId;
342
+ const existingByNodeId = explicitNodeId ? this.getByNodeId(explicitNodeId) : undefined;
343
+ if (existingByPublicKey?.nodeId && !explicitNodeId) {
344
+ throw new Error(`Peer "${peer.publicKey}" requires an explicit durable nodeId before mutating a bound peer row`);
345
+ }
346
+ const nodeId = explicitNodeId ?? existingByPublicKey?.nodeId ?? null;
347
+ if (peer.signingPublicKey && !nodeId) {
348
+ throw new Error(`Peer "${peer.publicKey}" requires an explicit durable nodeId before binding a signing key`);
349
+ }
350
+ const endpointMutation = {
351
+ endpointHost: peer.endpointHost,
352
+ endpointPort: peer.endpointPort,
353
+ endpointRevision: peer.endpointRevision,
354
+ controlEndpointHost: peer.controlEndpointHost,
355
+ controlEndpointPort: peer.controlEndpointPort,
356
+ controlTlsCaFingerprint: peer.controlTlsCaFingerprint,
357
+ };
358
+ const now = Date.now();
359
+ if (existingByNodeId) {
360
+ // A durable principal owns exactly one canonical transport binding. When
361
+ // the transport key rotates, rebind the existing row instead of creating
362
+ // a second row keyed by the stale transport identity.
363
+ if (existingByNodeId.publicKey !== peer.publicKey && nodeId) {
364
+ this.db
365
+ .prepare(`DELETE FROM network_peers WHERE public_key = ? AND node_id != ?`)
366
+ .run(peer.publicKey, nodeId);
367
+ }
368
+ const resolvedEndpointState = resolveAdvertisedEndpointState(existingByNodeId, endpointMutation);
369
+ this.db
370
+ .prepare(`UPDATE network_peers
371
+ SET node_id = ?,
372
+ public_key = ?,
373
+ name = ?,
374
+ endpoint_host = ?,
375
+ endpoint_port = ?,
376
+ endpoint_revision = ?,
377
+ control_endpoint_host = ?,
378
+ control_endpoint_port = ?,
379
+ control_tls_ca_fingerprint = ?,
380
+ preshared_key = COALESCE(?, preshared_key),
381
+ allowed_ips = COALESCE(?, allowed_ips),
382
+ invite_token = COALESCE(?, invite_token),
383
+ status = COALESCE(?, status),
384
+ updated_at = ?,
385
+ signing_public_key = COALESCE(?, signing_public_key)
386
+ WHERE node_id = ?`)
387
+ .run(nodeId, peer.publicKey, peer.name, resolvedEndpointState.endpointHost, resolvedEndpointState.endpointPort, resolvedEndpointState.endpointRevision, resolvedEndpointState.controlEndpointHost, resolvedEndpointState.controlEndpointPort, resolvedEndpointState.controlTlsCaFingerprint, peer.presharedKey ?? null, peer.allowedIps ?? null, peer.inviteToken ?? null, peer.status ?? "active", now, peer.signingPublicKey ?? null, nodeId);
388
+ return;
389
+ }
390
+ if (existingByPublicKey) {
391
+ const resolvedEndpointState = resolveAdvertisedEndpointState(existingByPublicKey, endpointMutation);
392
+ this.db
393
+ .prepare(`UPDATE network_peers
394
+ SET public_key = ?,
395
+ node_id = ?,
396
+ name = ?,
397
+ endpoint_host = ?,
398
+ endpoint_port = ?,
399
+ endpoint_revision = ?,
400
+ control_endpoint_host = ?,
401
+ control_endpoint_port = ?,
402
+ control_tls_ca_fingerprint = ?,
403
+ preshared_key = COALESCE(?, preshared_key),
404
+ allowed_ips = COALESCE(?, allowed_ips),
405
+ invite_token = COALESCE(?, invite_token),
406
+ status = COALESCE(?, status),
407
+ updated_at = ?,
408
+ signing_public_key = COALESCE(?, signing_public_key)
409
+ WHERE public_key = ?`)
410
+ .run(peer.publicKey, nodeId, peer.name, resolvedEndpointState.endpointHost, resolvedEndpointState.endpointPort, resolvedEndpointState.endpointRevision, resolvedEndpointState.controlEndpointHost, resolvedEndpointState.controlEndpointPort, resolvedEndpointState.controlTlsCaFingerprint, peer.presharedKey ?? null, peer.allowedIps ?? null, peer.inviteToken ?? null, peer.status ?? "active", now, peer.signingPublicKey ?? null, peer.publicKey);
411
+ return;
412
+ }
413
+ const resolvedEndpointState = resolveAdvertisedEndpointState(null, endpointMutation);
414
+ this.db
415
+ .prepare(`INSERT INTO network_peers (node_id, public_key, name, endpoint_host, endpoint_port, endpoint_revision,
416
+ control_endpoint_host, control_endpoint_port, control_tls_ca_fingerprint,
417
+ preshared_key, allowed_ips, invite_token, status, created_at, updated_at, signing_public_key)
418
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
419
+ .run(nodeId, peer.publicKey, peer.name, resolvedEndpointState.endpointHost, resolvedEndpointState.endpointPort, resolvedEndpointState.endpointRevision, resolvedEndpointState.controlEndpointHost, resolvedEndpointState.controlEndpointPort, resolvedEndpointState.controlTlsCaFingerprint, peer.presharedKey ?? null, peer.allowedIps ?? null, peer.inviteToken ?? null, peer.status ?? "active", now, now, peer.signingPublicKey ?? null);
420
+ }
421
+ /** Get all active peers */
422
+ listActive() {
423
+ const rows = this.db
424
+ .prepare(`${PEER_INFO_SELECT} WHERE status = 'active' ORDER BY name`)
425
+ .all();
426
+ return rows.map((row) => buildPeerInfo(parsePeerInfoRow(row)));
427
+ }
428
+ /** Get all peers (any status) */
429
+ listAll() {
430
+ const rows = this.db.prepare(`${PEER_INFO_SELECT} ORDER BY name`).all();
431
+ return rows.map((row) => buildPeerInfo(parsePeerInfoRow(row)));
432
+ }
433
+ /** List peers by display name. Display names are not unique principals. */
434
+ listByName(name) {
435
+ const rows = this.db
436
+ .prepare(`${PEER_INFO_SELECT} WHERE name = ? ORDER BY publicKey`)
437
+ .all(name);
438
+ return rows.map((row) => buildPeerInfo(parsePeerInfoRow(row)));
439
+ }
440
+ /** Get a peer by name */
441
+ getByName(name) {
442
+ const matches = this.listByName(name);
443
+ if (matches.length !== 1)
444
+ return undefined;
445
+ return matches[0];
446
+ }
447
+ /** Get a peer by durable node identity */
448
+ getByNodeId(nodeId) {
449
+ const row = this.db
450
+ .prepare(`${PEER_INFO_SELECT}
451
+ WHERE node_id = ?
452
+ ORDER BY CASE WHEN status = 'revoked' THEN 1 ELSE 0 END, created_at DESC`)
453
+ .get(nodeId);
454
+ return row ? buildPeerInfo(parsePeerInfoRow(row)) : undefined;
455
+ }
456
+ /** Get a peer by durable transport identity */
457
+ getByPublicKey(publicKey) {
458
+ const row = this.db.prepare(`${PEER_INFO_SELECT} WHERE public_key = ?`).get(publicKey);
459
+ return row ? buildPeerInfo(parsePeerInfoRow(row)) : undefined;
460
+ }
461
+ /** Update peer signing key by durable peer identity */
462
+ setSigningKeyByPublicKey(publicKey, signingPublicKey) {
463
+ assertValidSigningPublicKeyB64(signingPublicKey, "Peer signing key");
464
+ const existingRow = this.db
465
+ .prepare(`SELECT node_id AS nodeId FROM network_peers WHERE public_key = ?`)
466
+ .get(publicKey);
467
+ if (!existingRow) {
468
+ throw new Error(`No peer row found for public key "${publicKey}"`);
469
+ }
470
+ const existingNodeId = parseOptionalNodeId(existingRow.nodeId);
471
+ if (!existingNodeId || existingNodeId === publicKey) {
472
+ throw new Error(`Peer "${publicKey}" requires a durable nodeId before persisting a signing key`);
473
+ }
474
+ this.db
475
+ .prepare(`UPDATE network_peers SET signing_public_key = ? WHERE public_key = ?`)
476
+ .run(signingPublicKey, publicKey);
477
+ }
478
+ /** Revoke a peer by durable node identity. */
479
+ revokeByNodeId(nodeId) {
480
+ this.db
481
+ .prepare(`UPDATE network_peers SET status = 'revoked', updated_at = ? WHERE node_id = ?`)
482
+ .run(Date.now(), nodeId);
483
+ }
484
+ /** Revoke a peer's access while preserving durable audit state for restart and introspection. */
485
+ revoke(publicKey) {
486
+ this.db
487
+ .prepare(`UPDATE network_peers SET status = 'revoked', updated_at = ? WHERE public_key = ?`)
488
+ .run(Date.now(), publicKey);
489
+ }
490
+ /** Delete a superseded transport binding by its public key. */
491
+ delete(publicKey) {
492
+ this.db.prepare(`DELETE FROM network_peers WHERE public_key = ?`).run(publicKey);
493
+ }
494
+ /** Check if a token nonce was already consumed */
495
+ isTokenConsumed(nonce) {
496
+ const row = this.db.prepare(`SELECT 1 FROM network_peers WHERE invite_token = ?`).get(nonce);
497
+ return row !== undefined;
498
+ }
499
+ /**
500
+ * P0-3: Atomically claim a token nonce. Returns true if this call claimed it
501
+ * (first use), false if already claimed. Uses a SQLite transaction to eliminate
502
+ * the race window between check and mark in acceptInvite.
503
+ */
504
+ claimToken(nonce) {
505
+ return this.db.transaction(() => {
506
+ // Check both the legacy peer table and the claims table
507
+ const inPeers = this.db
508
+ .prepare(`SELECT 1 FROM network_peers WHERE invite_token = ?`)
509
+ .get(nonce);
510
+ if (inPeers)
511
+ return false;
512
+ const inClaims = this.db.prepare(`SELECT 1 FROM token_claims WHERE nonce = ?`).get(nonce);
513
+ if (inClaims)
514
+ return false;
515
+ this.db
516
+ .prepare(`INSERT INTO token_claims (nonce, claimed_at) VALUES (?, ?)`)
517
+ .run(nonce, Date.now());
518
+ return true;
519
+ })();
520
+ }
521
+ /** Release a previously claimed token (on error rollback). */
522
+ releaseToken(nonce) {
523
+ this.db.prepare(`DELETE FROM token_claims WHERE nonce = ?`).run(nonce);
524
+ }
525
+ /** Remove stale token claims older than maxAge (default 24h). Returns count of removed claims. */
526
+ cleanupStaleClaims(maxAgeMs = 24 * 60 * 60 * 1000) {
527
+ const cutoff = Date.now() - maxAgeMs;
528
+ const result = this.db.prepare("DELETE FROM token_claims WHERE claimed_at < ?").run(cutoff);
529
+ return result.changes;
530
+ }
531
+ /**
532
+ * Delete disposable invite stubs that can never become active again.
533
+ * Revoked peers stay durable so restart reconciliation, audits, and operator
534
+ * surfaces can still explain why a peer disappeared from active routing.
535
+ * Returns count of removed rows.
536
+ */
537
+ pruneTerminalPeers() {
538
+ const result = this.db.prepare(`DELETE FROM network_peers WHERE status = 'pending'`).run();
539
+ return result.changes;
540
+ }
541
+ /** Update last handshake timestamp */
542
+ updateHandshake(publicKey) {
543
+ this.db
544
+ .prepare(`UPDATE network_peers SET last_handshake = ? WHERE public_key = ?`)
545
+ .run(Date.now(), publicKey);
546
+ }
547
+ /** Update last handshake timestamp by durable node identity. */
548
+ updateHandshakeByNodeId(nodeId) {
549
+ this.db
550
+ .prepare(`UPDATE network_peers SET last_handshake = ? WHERE node_id = ?`)
551
+ .run(Date.now(), nodeId);
552
+ }
553
+ /** Get peers by status */
554
+ listByStatus(status) {
555
+ const rows = this.db
556
+ .prepare(`${PEER_INFO_SELECT} WHERE status = ? ORDER BY name`)
557
+ .all(status);
558
+ return rows.map((row) => buildPeerInfo(parsePeerInfoRow(row)));
559
+ }
560
+ /** Get a pending peer by invite token nonce */
561
+ getPendingByInviteToken(inviteTokenNonce) {
562
+ const row = this.db
563
+ .prepare(`${PEER_INFO_SELECT} WHERE status = 'pending' AND invite_token = ? LIMIT 1`)
564
+ .get(inviteTokenNonce);
565
+ return row ? buildPeerInfo(parsePeerInfoRow(row)) : undefined;
566
+ }
567
+ /** Count active peers */
568
+ activeCount() {
569
+ const row = this.db
570
+ .prepare(`SELECT COUNT(*) as count FROM network_peers WHERE status = 'active'`)
571
+ .get();
572
+ return row.count;
573
+ }
574
+ /** Get a peer by public key with PSK for tunnel reconstruction */
575
+ getWithPsk(publicKey) {
576
+ const row = this.db
577
+ .prepare(`SELECT public_key, name, preshared_key, invite_token, endpoint_host, endpoint_port, status,
578
+ node_id, signing_public_key, endpoint_revision
579
+ FROM network_peers WHERE public_key = ?`)
580
+ .get(publicKey);
581
+ if (!row)
582
+ return undefined;
583
+ return {
584
+ publicKey: row.public_key,
585
+ nodeId: row.node_id ? tools_1.NodeIdSchema.parse(row.node_id) : null,
586
+ name: row.name,
587
+ presharedKey: row.preshared_key,
588
+ inviteToken: row.invite_token,
589
+ endpointHost: row.endpoint_host,
590
+ endpointPort: row.endpoint_port,
591
+ endpointRevision: row.endpoint_revision ?? 0,
592
+ status: row.status,
593
+ signingPublicKey: row.signing_public_key,
594
+ };
595
+ }
596
+ }
597
+ exports.PeerRegistry = PeerRegistry;
598
+ /**
599
+ * Generate an Ed25519 keypair for message signing.
600
+ * Returns base64-encoded SPKI DER (public) and PKCS#8 DER (private).
601
+ */
602
+ function generateSigningKeypair() {
603
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
604
+ publicKeyEncoding: { type: "spki", format: "der" },
605
+ privateKeyEncoding: { type: "pkcs8", format: "der" },
606
+ });
607
+ return {
608
+ publicKey: publicKey.toString("base64"),
609
+ privateKey: privateKey.toString("base64"),
610
+ };
611
+ }
612
+ /**
613
+ * Generate a new X25519 keypair using node:crypto.
614
+ */
615
+ function generateKeyPair() {
616
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519", {
617
+ publicKeyEncoding: { type: "spki", format: "der" },
618
+ privateKeyEncoding: { type: "pkcs8", format: "der" },
619
+ });
620
+ // Extract raw 32-byte keys from DER encoding
621
+ // X25519 SPKI DER: 12-byte header + 32-byte key
622
+ // X25519 PKCS8 DER: 16-byte header + 32-byte key
623
+ const rawPublic = publicKey.subarray(publicKey.length - 32);
624
+ const rawPrivate = privateKey.subarray(privateKey.length - 32);
625
+ return {
626
+ publicKey: rawPublic.toString("base64"),
627
+ privateKey: rawPrivate.toString("base64"),
628
+ };
629
+ }
630
+ function assertValidEndpointHost(host, label) {
631
+ if (host.length < 1 || host.length > 253) {
632
+ throw new Error(`${label} must be 1-253 characters`);
633
+ }
634
+ if (!/^[a-zA-Z0-9._:-]+$/.test(host)) {
635
+ throw new Error(`${label} contains invalid characters`);
636
+ }
637
+ if (host.includes("..")) {
638
+ throw new Error(`${label} cannot contain consecutive dots`);
639
+ }
640
+ }
641
+ function assertValidPort(port, label) {
642
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
643
+ throw new Error(`${label} must be an integer between 1 and 65535`);
644
+ }
645
+ }
646
+ function assertValidPeerPublicKeyB64(peerPublicKey, label) {
647
+ const keyBytes = Buffer.from(peerPublicKey, "base64");
648
+ if (keyBytes.length !== 32) {
649
+ throw new Error(`${label} must be a base64-encoded 32-byte X25519 public key`);
650
+ }
651
+ }
652
+ function assertValidSigningPublicKeyB64(signingPublicKey, label) {
653
+ try {
654
+ const key = crypto.createPublicKey({
655
+ key: Buffer.from(signingPublicKey, "base64"),
656
+ format: "der",
657
+ type: "spki",
658
+ });
659
+ if (key.asymmetricKeyType !== "ed25519") {
660
+ throw new Error("wrong key type");
661
+ }
662
+ }
663
+ catch {
664
+ throw new Error(`${label} must be a valid base64 SPKI Ed25519 public key`);
665
+ }
666
+ }
667
+ function evaluateEndpointRevision(params) {
668
+ const currentRevision = params.currentRevision ?? 0;
669
+ if (params.nextRevision < currentRevision) {
670
+ return { kind: "stale", currentRevision };
671
+ }
672
+ if (params.nextRevision === currentRevision) {
673
+ const sameEndpoint = params.currentHost === params.nextHost && params.currentPort === params.nextPort;
674
+ return sameEndpoint
675
+ ? { kind: "idempotent", revision: currentRevision }
676
+ : { kind: "conflict", currentRevision };
677
+ }
678
+ return { kind: "apply", revision: params.nextRevision };
679
+ }
680
+ /**
681
+ * Validate that a decoded invite token has all required fields with correct types.
682
+ */
683
+ function validateInviteToken(data) {
684
+ const parsed = tools_1.TransportInviteTokenSchema.safeParse(data);
685
+ if (!parsed.success) {
686
+ throw new Error(`Invalid token: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
687
+ }
688
+ return parsed.data;
689
+ }
690
+ function canonicalizeInviteTokenPayload(invite) {
691
+ return JSON.stringify({
692
+ nodeId: invite.nodeId,
693
+ audienceNodeId: invite.audienceNodeId,
694
+ publicKey: invite.publicKey,
695
+ leaderDisplayNameSnapshot: invite.leaderDisplayNameSnapshot,
696
+ host: invite.host,
697
+ port: invite.port,
698
+ controlEndpoint: invite.controlEndpoint,
699
+ psk: invite.psk,
700
+ displayNameSnapshot: invite.displayNameSnapshot,
701
+ signingPublicKey: invite.signingPublicKey,
702
+ caCert: invite.caCert,
703
+ createdAt: invite.createdAt,
704
+ expiresAt: invite.expiresAt,
705
+ tokenNonce: invite.tokenNonce,
706
+ coordinationUrl: invite.coordinationUrl,
707
+ networkId: invite.networkId,
708
+ });
709
+ }
710
+ /**
711
+ * Create an invite token for a new peer.
712
+ * Encodes: leader's public key + endpoint + PSK + display snapshots.
713
+ *
714
+ * Token format: aria-net-v4-<base64url(JSON{payload,signature})>
715
+ * The payload is always signed with Ed25519.
716
+ */
717
+ function createInviteToken(params) {
718
+ if (!params.signingPrivateKey || !params.signingPublicKey) {
719
+ throw new Error("Signed invite tokens require signingPrivateKey and signingPublicKey (v3 tokens are removed)");
720
+ }
721
+ assertNoUnexpectedActorKeys(params, [
722
+ "leaderPublicKey",
723
+ "nodeId",
724
+ "audienceNodeId",
725
+ "leaderDisplayNameSnapshot",
726
+ "host",
727
+ "port",
728
+ "controlEndpoint",
729
+ "displayNameSnapshot",
730
+ "durationMs",
731
+ "signingPublicKey",
732
+ "caCert",
733
+ "coordinationUrl",
734
+ "networkId",
735
+ "signingPrivateKey",
736
+ ], "Signed invite token");
737
+ const rawNodeId = params.nodeId?.trim();
738
+ if (!rawNodeId) {
739
+ throw new Error("Signed invite tokens require an explicit durable nodeId");
740
+ }
741
+ const nodeId = tools_1.NodeIdSchema.parse(rawNodeId);
742
+ const audienceNodeId = params.audienceNodeId
743
+ ? tools_1.NodeIdSchema.parse(params.audienceNodeId)
744
+ : undefined;
745
+ const leaderPublicKey = tools_1.PeerTransportIdSchema.parse(params.leaderPublicKey);
746
+ const signingPublicKey = tools_1.SigningPublicKeySchema.parse(params.signingPublicKey);
747
+ const DEFAULT_INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000;
748
+ const psk = crypto.randomBytes(32).toString("base64");
749
+ const tokenNonce = crypto.randomBytes(16).toString("hex");
750
+ const hasCustomDuration = typeof params.durationMs === "number";
751
+ const durationMs = params.durationMs ?? DEFAULT_INVITE_EXPIRY_MS;
752
+ const createdAt = Date.now();
753
+ const expiresAt = hasCustomDuration
754
+ ? durationMs === 0
755
+ ? 0
756
+ : Math.min(createdAt + durationMs, Number.MAX_SAFE_INTEGER)
757
+ : Math.min(createdAt + DEFAULT_INVITE_EXPIRY_MS, Number.MAX_SAFE_INTEGER);
758
+ const payload = {
759
+ nodeId,
760
+ audienceNodeId,
761
+ publicKey: leaderPublicKey,
762
+ leaderDisplayNameSnapshot: params.leaderDisplayNameSnapshot,
763
+ host: params.host,
764
+ port: params.port,
765
+ controlEndpoint: params.controlEndpoint,
766
+ psk,
767
+ displayNameSnapshot: params.displayNameSnapshot,
768
+ signingPublicKey,
769
+ caCert: params.caCert,
770
+ createdAt,
771
+ expiresAt,
772
+ tokenNonce,
773
+ coordinationUrl: params.coordinationUrl,
774
+ networkId: params.networkId,
775
+ };
776
+ const json = canonicalizeInviteTokenPayload(payload);
777
+ const keyDer = Buffer.from(params.signingPrivateKey, "base64");
778
+ const privateKey = crypto.createPrivateKey({ key: keyDer, format: "der", type: "pkcs8" });
779
+ const signature = crypto.sign(null, Buffer.from(json), privateKey).toString("base64");
780
+ const signedPayload = JSON.stringify({ payload, signature });
781
+ const data = Buffer.from(signedPayload).toString("base64url");
782
+ return { token: `aria-net-v4-${data}`, psk };
783
+ }
784
+ /**
785
+ * Decode an invite token.
786
+ *
787
+ * Accepts v4 (aria-net-v4-, Ed25519 signed) format only.
788
+ */
789
+ function decodeInviteToken(token) {
790
+ if (!token.startsWith("aria-net-")) {
791
+ throw new Error("Invalid invite token format — must start with aria-net-");
792
+ }
793
+ // v4: signed token — verify signature against included public key
794
+ if (token.startsWith("aria-net-v4-")) {
795
+ const dataB64 = token.slice("aria-net-v4-".length);
796
+ const json = Buffer.from(dataB64, "base64url").toString("utf-8");
797
+ const parsed = JSON.parse(json);
798
+ if (!parsed.payload || !parsed.signature) {
799
+ throw new Error("Invalid v4 token: missing payload or signature");
800
+ }
801
+ const rawPayload = parsed.payload;
802
+ const signingPublicKey = typeof rawPayload.signingPublicKey === "string" ? rawPayload.signingPublicKey : undefined;
803
+ if (!signingPublicKey) {
804
+ throw new Error("Invalid v4 token: missing signingPublicKey for signature verification");
805
+ }
806
+ const payloadJson = JSON.stringify(parsed.payload);
807
+ const pubKeyDer = Buffer.from(signingPublicKey, "base64");
808
+ const publicKey = crypto.createPublicKey({ key: pubKeyDer, format: "der", type: "spki" });
809
+ const valid = crypto.verify(null, Buffer.from(payloadJson), publicKey, Buffer.from(parsed.signature, "base64"));
810
+ if (!valid) {
811
+ throw new Error("Invalid v4 token: signature verification failed");
812
+ }
813
+ return validateInviteToken(parsed.payload);
814
+ }
815
+ // Explicitly reject all legacy formats.
816
+ if (token.startsWith("aria-net-v2-")) {
817
+ throw new Error("v2 tokens are no longer accepted — the HMAC key was included in the token (forgeable). Generate a new signed v4 invite token.");
818
+ }
819
+ if (token.startsWith("aria-net-") && token.split(".").length === 4) {
820
+ throw new Error("v1 encrypted tokens are no longer accepted — the AES key was included in the token. Generate a new signed v4 invite token.");
821
+ }
822
+ throw new Error("Legacy invite tokens are no longer accepted. Generate a new signed v4 invite token.");
823
+ }
824
+ function normalizeTrustedCaId(fingerprint) {
825
+ const normalized = fingerprint.trim().toLowerCase();
826
+ if (!/^[a-f0-9]{16,128}$/.test(normalized)) {
827
+ throw new Error("Invalid trusted CA fingerprint");
828
+ }
829
+ return normalized;
830
+ }
831
+ function trustedCaPathForFingerprint(ariaDir, fingerprint) {
832
+ const trustedDir = path.resolve(path.join(ariaDir, "network", "trusted-cas"));
833
+ const targetPath = path.resolve(path.join(trustedDir, `${normalizeTrustedCaId(fingerprint)}.pem`));
834
+ if (!targetPath.startsWith(`${trustedDir}${path.sep}`)) {
835
+ throw new Error("Trusted CA path escapes trusted-cas directory");
836
+ }
837
+ return targetPath;
838
+ }
839
+ function persistTrustedPeerCa(ariaDir, caCert) {
840
+ const trimmedCert = caCert?.trim();
841
+ if (!trimmedCert)
842
+ return;
843
+ const fingerprint = tools_1.TlsCaFingerprintSchema.parse(crypto
844
+ .createHash("sha256")
845
+ .update(Buffer.from(trimmedCert
846
+ .replace(/-----BEGIN CERTIFICATE-----/, "")
847
+ .replace(/-----END CERTIFICATE-----/, "")
848
+ .replace(/\s/g, ""), "base64"))
849
+ .digest("hex"));
850
+ const targetPath = trustedCaPathForFingerprint(ariaDir, fingerprint);
851
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
852
+ fs.writeFileSync(targetPath, trimmedCert, "utf8");
853
+ }
854
+ function solveJoinProofOfWork(challengeString, difficulty) {
855
+ const [challengeId, challenge] = challengeString.split(":");
856
+ if (!challengeId || !challenge) {
857
+ throw new Error("Join challenge payload is malformed");
858
+ }
859
+ const targetPrefix = "0".repeat(Math.max(0, difficulty));
860
+ const MAX_NONCE_ATTEMPTS = 5_000_000;
861
+ for (let nonce = 0; nonce < MAX_NONCE_ATTEMPTS; nonce++) {
862
+ const hash = crypto
863
+ .createHash("sha256")
864
+ .update(challenge + nonce.toString())
865
+ .digest("hex");
866
+ if (hash.startsWith(targetPrefix)) {
867
+ return `${challengeId}:${nonce}`;
868
+ }
869
+ }
870
+ throw new Error("Failed to solve join proof-of-work within attempt budget");
871
+ }
872
+ /**
873
+ * Ensure a secure network exists — auto-creates on first run.
874
+ *
875
+ * This is the zero-configuration entry point. Called during ARIA startup.
876
+ * If no network config exists, generates keypair, picks a random port,
877
+ * discovers external endpoint via STUN, and saves the config.
878
+ */
879
+ async function ensureSecureNetwork(ariaDir, arionName = "ARIA", options = {}) {
880
+ const networkDir = path.join(ariaDir, "network");
881
+ const configPath = path.join(networkDir, "config.json");
882
+ const authoritativeNodeId = options.nodeId ? tools_1.NodeIdSchema.parse(options.nodeId) : undefined;
883
+ // Check for existing config
884
+ if (fs.existsSync(configPath)) {
885
+ // Verify file permissions haven't been weakened (private key protection)
886
+ const stat = fs.statSync(configPath);
887
+ const mode = stat.mode & 0o777;
888
+ if (mode !== 0o600) {
889
+ // Attempt to fix permissions
890
+ try {
891
+ fs.chmodSync(configPath, 0o600);
892
+ }
893
+ catch {
894
+ // Can't fix — log warning but continue
895
+ wireguardDebug(`[wireguard] WARNING: config.json has permissions ${mode.toString(8)} (expected 600). Contains private key.\n`);
896
+ }
897
+ }
898
+ const data = fs.readFileSync(configPath, "utf-8");
899
+ const config = JSON.parse(data);
900
+ if (config.nodeId) {
901
+ config.nodeId = tools_1.NodeIdSchema.parse(config.nodeId);
902
+ }
903
+ if (typeof config.publicKey === "string" && config.publicKey.length > 0) {
904
+ config.publicKey = tools_1.PeerTransportIdSchema.parse(config.publicKey);
905
+ }
906
+ // Migration: generate missing keys for configs with partial fields.
907
+ // The config may have been created with only signing keys (e.g., test harness
908
+ // or manual setup) — we need WireGuard X25519 keys for tunnel operations.
909
+ let needsPersist = false;
910
+ if (!config.publicKey || !config.privateKey) {
911
+ const keyPair = generateKeyPair();
912
+ config.publicKey = tools_1.PeerTransportIdSchema.parse(keyPair.publicKey);
913
+ config.privateKey = keyPair.privateKey;
914
+ needsPersist = true;
915
+ }
916
+ if (typeof config.listenPort !== "number" || Number.isNaN(config.listenPort)) {
917
+ config.listenPort = resolveDefaultWireguardListenPort();
918
+ needsPersist = true;
919
+ }
920
+ if (typeof config.createdAt !== "number" ||
921
+ !Number.isFinite(config.createdAt) ||
922
+ config.createdAt <= 0) {
923
+ config.createdAt = Date.now();
924
+ needsPersist = true;
925
+ }
926
+ if (typeof config.endpointRevision !== "number" ||
927
+ !Number.isInteger(config.endpointRevision) ||
928
+ config.endpointRevision < 0) {
929
+ config.endpointRevision = 0;
930
+ needsPersist = true;
931
+ }
932
+ if (!config.signingPublicKey || !config.signingPrivateKey) {
933
+ const signingKeypair = generateSigningKeypair();
934
+ config.signingPublicKey = signingKeypair.publicKey;
935
+ config.signingPrivateKey = signingKeypair.privateKey;
936
+ needsPersist = true;
937
+ }
938
+ if (!config.nodeId) {
939
+ if (!authoritativeNodeId) {
940
+ throw new Error("Network config missing nodeId — runtime bootstrap authority must provide nodeId before network initialization");
941
+ }
942
+ config.nodeId = authoritativeNodeId;
943
+ needsPersist = true;
944
+ }
945
+ if (authoritativeNodeId && config.nodeId !== authoritativeNodeId) {
946
+ throw new Error(`Network config nodeId mismatch: expected ${authoritativeNodeId}, found ${config.nodeId}`);
947
+ }
948
+ if (typeof config.endpointRevision !== "number" || config.endpointRevision < 0) {
949
+ config.endpointRevision = config.externalEndpoint ? 1 : 0;
950
+ needsPersist = true;
951
+ }
952
+ const expectedDisplaySnapshot = deriveLocalDisplayNameSnapshot(arionName);
953
+ const legacyLocalPeerName = typeof config.localPeerName === "string" ? config.localPeerName.trim() : "";
954
+ const currentDisplaySnapshot = config.localDisplayNameSnapshot?.trim() ?? "";
955
+ const preferredDisplaySnapshot = currentDisplaySnapshot.length > 0 ? currentDisplaySnapshot : legacyLocalPeerName;
956
+ if (preferredDisplaySnapshot.length === 0) {
957
+ config.localDisplayNameSnapshot = expectedDisplaySnapshot;
958
+ needsPersist = true;
959
+ }
960
+ else if (preferredDisplaySnapshot === arionName ||
961
+ matchesLegacyDerivedLocalPeerName(preferredDisplaySnapshot, arionName)) {
962
+ config.localDisplayNameSnapshot = expectedDisplaySnapshot;
963
+ needsPersist = true;
964
+ }
965
+ else if (config.localDisplayNameSnapshot !== preferredDisplaySnapshot) {
966
+ config.localDisplayNameSnapshot = preferredDisplaySnapshot;
967
+ needsPersist = true;
968
+ }
969
+ if ("localPeerName" in config) {
970
+ delete config.localPeerName;
971
+ needsPersist = true;
972
+ }
973
+ if (needsPersist) {
974
+ try {
975
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
976
+ }
977
+ catch {
978
+ // Non-fatal: keys are in memory even if we can't persist them
979
+ }
980
+ }
981
+ return config;
982
+ }
983
+ // Generate new network
984
+ if (!authoritativeNodeId) {
985
+ throw new Error("Network bootstrap requires an explicit runtime-owned nodeId before WireGuard initialization");
986
+ }
987
+ const keyPair = generateKeyPair();
988
+ const signingKeypair = generateSigningKeypair();
989
+ // Default UDP listen port for ARIA secure networking. Fixed (not
990
+ // random) so firewall rules and NAT port forwarding work across
991
+ // restarts. High port (49151+) avoids port scanning and IANA conflicts.
992
+ const listenPort = resolveDefaultWireguardListenPort();
993
+ const localDisplayNameSnapshot = deriveLocalDisplayNameSnapshot(arionName);
994
+ const config = {
995
+ nodeId: authoritativeNodeId,
996
+ publicKey: tools_1.PeerTransportIdSchema.parse(keyPair.publicKey),
997
+ privateKey: keyPair.privateKey,
998
+ signingPublicKey: signingKeypair.publicKey,
999
+ signingPrivateKey: signingKeypair.privateKey,
1000
+ listenPort,
1001
+ endpointRevision: 0,
1002
+ createdAt: Date.now(),
1003
+ localDisplayNameSnapshot,
1004
+ };
1005
+ // Save config — atomic create-or-fail to avoid TOCTOU race
1006
+ fs.mkdirSync(networkDir, { recursive: true, mode: 0o700 });
1007
+ try {
1008
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600, flag: "wx" });
1009
+ }
1010
+ catch (err) {
1011
+ if (err.code === "EEXIST") {
1012
+ // Another process created the config — read and use theirs
1013
+ const data = fs.readFileSync(configPath, "utf-8");
1014
+ const existing = JSON.parse(data);
1015
+ if (existing.nodeId) {
1016
+ existing.nodeId = tools_1.NodeIdSchema.parse(existing.nodeId);
1017
+ }
1018
+ if (!existing.nodeId) {
1019
+ throw new Error("Concurrent network bootstrap produced config without nodeId — refusing bootstrap");
1020
+ }
1021
+ if (existing.nodeId !== authoritativeNodeId) {
1022
+ throw new Error(`Concurrent network bootstrap nodeId mismatch: expected ${authoritativeNodeId}, found ${existing.nodeId}`);
1023
+ }
1024
+ return existing;
1025
+ }
1026
+ throw err;
1027
+ }
1028
+ return config;
1029
+ }
1030
+ /**
1031
+ * NetworkManager — manages the lifecycle of all peer tunnels.
1032
+ *
1033
+ * Loads peers from DB on start, creates/destroys tunnels on demand,
1034
+ * handles invite acceptance, and provides status queries.
1035
+ */
1036
+ class NetworkManager {
1037
+ static MAX_PEERS = 128;
1038
+ /** Interval for attempting direct connection upgrade when using relay */
1039
+ static RELAY_UPGRADE_INTERVAL_MS = 5 * 60_000; // 5 min
1040
+ config = null;
1041
+ tunnels = new Map();
1042
+ /** In-flight tunnel startups keyed by peer public key. */
1043
+ pendingTunnelStarts = new Map();
1044
+ /** Pending starts that must self-cancel before publishing an active tunnel. */
1045
+ canceledTunnelStarts = new Set();
1046
+ /** Relay transports for peers behind symmetric NAT (publicKey -> relay) */
1047
+ relayTransports = new Map();
1048
+ /** Periodic timers for attempting direct connection upgrade from relay */
1049
+ relayUpgradeTimers = new Map();
1050
+ /** Known peer Ed25519 signing public keys keyed by display-name snapshot. */
1051
+ _peerSigningKeys = new Map();
1052
+ /** Public display snapshot surfaced through runtime/publication boundaries. */
1053
+ _localDisplayNameSnapshot;
1054
+ /** Our durable local node identity projected from the runtime authority. */
1055
+ _localNodeId;
1056
+ /** Runtime-published local control endpoint advertisement for invite/join flows. */
1057
+ _localControlEndpoint;
1058
+ /** Cached bootstrap CA authority used to mint pinned invite tokens. */
1059
+ _localBootstrapCaCert;
1060
+ /** Consecutive failed upgrade attempts per relay (for exponential backoff) */
1061
+ relayUpgradeAttempts = new Map();
1062
+ appendDiagnostic(entry) {
1063
+ appendWireguardDiagnostic(this.ariaDir, entry);
1064
+ }
1065
+ /** Callback for relay data received through DERP relay.
1066
+ * Set by daemon to wire relay-received messages into the EventQueue. */
1067
+ onRelayDataReceived;
1068
+ // ── Listener-based event system (replaces singleton callbacks) ──────────
1069
+ _transportListeners = [];
1070
+ _messageListeners = [];
1071
+ _deliveryAckListeners = [];
1072
+ _peerActivationListeners = [];
1073
+ /** Subscribe to transport events (tunnel/relay up/down). Returns unsubscribe fn. */
1074
+ addTransportListener(listener) {
1075
+ this._transportListeners.push(listener);
1076
+ return () => {
1077
+ const idx = this._transportListeners.indexOf(listener);
1078
+ if (idx >= 0)
1079
+ this._transportListeners.splice(idx, 1);
1080
+ };
1081
+ }
1082
+ /** Subscribe to incoming messages. Returns unsubscribe fn. */
1083
+ addMessageListener(listener) {
1084
+ this._messageListeners.push(listener);
1085
+ return () => {
1086
+ const idx = this._messageListeners.indexOf(listener);
1087
+ if (idx >= 0)
1088
+ this._messageListeners.splice(idx, 1);
1089
+ };
1090
+ }
1091
+ /** Subscribe to delivery acknowledgements. Returns unsubscribe fn. */
1092
+ addDeliveryAckListener(listener) {
1093
+ this._deliveryAckListeners.push(listener);
1094
+ return () => {
1095
+ const idx = this._deliveryAckListeners.indexOf(listener);
1096
+ if (idx >= 0)
1097
+ this._deliveryAckListeners.splice(idx, 1);
1098
+ };
1099
+ }
1100
+ /** Subscribe to verified peer activation events. Returns unsubscribe fn. */
1101
+ addPeerActivationListener(listener) {
1102
+ this._peerActivationListeners.push(listener);
1103
+ return () => {
1104
+ const idx = this._peerActivationListeners.indexOf(listener);
1105
+ if (idx >= 0)
1106
+ this._peerActivationListeners.splice(idx, 1);
1107
+ };
1108
+ }
1109
+ /** Get a snapshot of currently active transports (for late-bind mailbox scenarios). */
1110
+ getActiveTransports() {
1111
+ const result = [];
1112
+ const includedPubKeys = new Set();
1113
+ for (const [pubKey, tunnel] of this.tunnels) {
1114
+ const routeOwnership = this.getDirectRouteOwnershipForPublicKey(pubKey);
1115
+ if (routeOwnership?.ownership === "superseded") {
1116
+ continue;
1117
+ }
1118
+ const tunnelState = typeof tunnel.getState === "function" ? tunnel.getState() : "connected";
1119
+ if (tunnelState !== "connected" && tunnelState !== "handshaking") {
1120
+ continue;
1121
+ }
1122
+ const peerInfo = this.peerRegistry?.getWithPsk(pubKey);
1123
+ if (peerInfo) {
1124
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
1125
+ if (!nodeId)
1126
+ continue;
1127
+ result.push({
1128
+ type: "tunnel",
1129
+ nodeId,
1130
+ displayNameSnapshot: peerInfo.name,
1131
+ transport: tunnelState === "connected"
1132
+ ? tunnel
1133
+ : {
1134
+ sendPlaintext: (data) => tunnel.sendPlaintext(data),
1135
+ routeBootstrapOnly: true,
1136
+ },
1137
+ });
1138
+ includedPubKeys.add(pubKey);
1139
+ }
1140
+ }
1141
+ for (const [pubKey, relay] of this.relayTransports) {
1142
+ if (includedPubKeys.has(pubKey))
1143
+ continue;
1144
+ const routeOwnership = this.getDirectRouteOwnershipForPublicKey(pubKey);
1145
+ if (routeOwnership?.ownership === "superseded") {
1146
+ continue;
1147
+ }
1148
+ const peerInfo = this.peerRegistry?.getWithPsk(pubKey);
1149
+ if (peerInfo) {
1150
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
1151
+ if (!nodeId)
1152
+ continue;
1153
+ result.push({
1154
+ type: "relay",
1155
+ nodeId,
1156
+ displayNameSnapshot: peerInfo.name,
1157
+ transport: { sendPlaintext: (data) => relay.send(data) },
1158
+ });
1159
+ }
1160
+ }
1161
+ return result;
1162
+ }
1163
+ /** Shared UDP socket for all WG tunnels (single-port model like real WireGuard).
1164
+ * Created in initialize(), bound to config.listenPort. All tunnels send/receive
1165
+ * through this one socket. Incoming packets are offered to registered tunnel
1166
+ * handlers until one claims ownership. */
1167
+ sharedSocket = null;
1168
+ /** Packet handlers registered by tunnels using the shared socket.
1169
+ * Each handler returns a disposition object indicating whether it handled the packet. */
1170
+ sharedSocketHandlers = new Set();
1171
+ /** ExternalSocket interface exposed to SecureTunnels */
1172
+ get externalSocketInterface() {
1173
+ if (!this.sharedSocket)
1174
+ return undefined;
1175
+ const socket = this.sharedSocket;
1176
+ const handlers = this.sharedSocketHandlers;
1177
+ return {
1178
+ send: (data, port, host, cb) => {
1179
+ socket.send(data, 0, data.length, port, host, cb);
1180
+ },
1181
+ port: this.config?.listenPort ?? 0,
1182
+ onPacket: (handler) => {
1183
+ handlers.add(handler);
1184
+ return () => {
1185
+ handlers.delete(handler);
1186
+ };
1187
+ },
1188
+ };
1189
+ }
1190
+ ariaDir;
1191
+ arionName;
1192
+ peerRegistry;
1193
+ constructor(ariaDir, arionNameOrPeerRegistry, peerRegistry) {
1194
+ this.ariaDir = ariaDir;
1195
+ if (typeof arionNameOrPeerRegistry === "string") {
1196
+ this.arionName = arionNameOrPeerRegistry;
1197
+ this.peerRegistry = peerRegistry;
1198
+ }
1199
+ else {
1200
+ this.arionName = "ARIA";
1201
+ this.peerRegistry = arionNameOrPeerRegistry;
1202
+ }
1203
+ }
1204
+ isLocalIdentityCandidate(candidate) {
1205
+ if (!this.config)
1206
+ return false;
1207
+ if (candidate.publicKey && candidate.publicKey === this.config.publicKey)
1208
+ return true;
1209
+ if (candidate.signingPublicKey &&
1210
+ this.config.signingPublicKey &&
1211
+ candidate.signingPublicKey === this.config.signingPublicKey) {
1212
+ return true;
1213
+ }
1214
+ return false;
1215
+ }
1216
+ /**
1217
+ * Remote peers and the local machine identity must never share the same registry.
1218
+ * This guard makes the invalid state unrepresentable for all peer-registration paths.
1219
+ */
1220
+ assertRemotePeerIdentity(candidate, context = "Peer") {
1221
+ if (this.isLocalIdentityCandidate(candidate)) {
1222
+ const candidateName = candidate.name ? ` "${candidate.name}"` : "";
1223
+ throw new Error(`${context}${candidateName} matches the local machine identity`);
1224
+ }
1225
+ }
1226
+ quarantineCorruptedLocalPeerRows() {
1227
+ if (!this.peerRegistry || !this.config)
1228
+ return;
1229
+ for (const peer of this.peerRegistry.listAll()) {
1230
+ if (!this.isLocalIdentityCandidate(peer))
1231
+ continue;
1232
+ this.peerRegistry.delete(peer.publicKey);
1233
+ this._peerSigningKeys.delete(peer.publicKey);
1234
+ wireguardDebug(`[wireguard] Quarantined corrupted local identity row "${peer.name}" (${peer.publicKey.slice(0, 8)}...)\n`);
1235
+ }
1236
+ }
1237
+ resolveUniqueLivePeerPublicKeyByNodeId(nodeId) {
1238
+ const peer = this.resolvePeerByNodeId(nodeId, { includeRevoked: false });
1239
+ return peer?.publicKey ?? null;
1240
+ }
1241
+ getDirectRouteOwnershipSnapshot() {
1242
+ if (!this.peerRegistry) {
1243
+ return new Map();
1244
+ }
1245
+ return (0, route_ownership_js_1.resolveDirectRouteOwnership)(this.peerRegistry
1246
+ .listAll()
1247
+ .filter((peer) => !this.isLocalIdentityCandidate(peer))
1248
+ .filter((peer) => peer.status !== "revoked")
1249
+ .map((peer) => ({
1250
+ publicKey: peer.publicKey,
1251
+ nodeId: peer.nodeId,
1252
+ status: peer.status,
1253
+ endpointHost: peer.endpointHost,
1254
+ endpointPort: peer.endpointPort,
1255
+ endpointRevision: peer.endpointRevision,
1256
+ createdAt: peer.createdAt,
1257
+ updatedAt: peer.updatedAt,
1258
+ })));
1259
+ }
1260
+ getDirectRouteOwnershipForPublicKey(peerPublicKey) {
1261
+ return this.getDirectRouteOwnershipSnapshot().get(peerPublicKey) ?? null;
1262
+ }
1263
+ resolveCurrentDirectRoutePeerPublicKeyByNodeId(nodeId) {
1264
+ const peer = this.resolvePeerByNodeId(nodeId, { includeRevoked: false });
1265
+ if (!peer) {
1266
+ return null;
1267
+ }
1268
+ const ownership = this.getDirectRouteOwnershipForPublicKey(peer.publicKey);
1269
+ if (ownership?.ownership === "superseded") {
1270
+ return null;
1271
+ }
1272
+ return peer.publicKey;
1273
+ }
1274
+ ensureCurrentDirectRouteOwner(peerPublicKey) {
1275
+ const ownership = this.getDirectRouteOwnershipForPublicKey(peerPublicKey);
1276
+ if (ownership?.ownership === "superseded") {
1277
+ throw new Error(`Peer ${peerPublicKey} has a superseded route claim`);
1278
+ }
1279
+ }
1280
+ reconcileDirectRouteOwnership() {
1281
+ if (!this.peerRegistry) {
1282
+ return;
1283
+ }
1284
+ for (const [peerPublicKey, ownership] of this.getDirectRouteOwnershipSnapshot()) {
1285
+ if (ownership.ownership !== "superseded") {
1286
+ continue;
1287
+ }
1288
+ const peerInfo = this.peerRegistry.getWithPsk(peerPublicKey);
1289
+ const ownerPeer = this.peerRegistry.getWithPsk(ownership.ownerPublicKey);
1290
+ this.appendDiagnostic({
1291
+ timestamp: Date.now(),
1292
+ event: "route_superseded",
1293
+ peerPublicKey,
1294
+ peerNodeId: peerInfo?.nodeId ?? null,
1295
+ peerName: peerInfo?.name,
1296
+ detail: {
1297
+ ownerPublicKey: ownership.ownerPublicKey,
1298
+ ownerNodeId: ownerPeer?.nodeId ?? null,
1299
+ routeKey: ownership.routeKey,
1300
+ },
1301
+ });
1302
+ this.tearDownRelay(peerPublicKey);
1303
+ this.stopTunnelByTransportKey(peerPublicKey);
1304
+ }
1305
+ }
1306
+ resolvePeerByNodeId(nodeId, options) {
1307
+ if (!this.peerRegistry) {
1308
+ return null;
1309
+ }
1310
+ const peer = this.peerRegistry.getByNodeId(nodeId);
1311
+ if (!peer) {
1312
+ return null;
1313
+ }
1314
+ if (!options.includeRevoked && peer.status === "revoked") {
1315
+ return null;
1316
+ }
1317
+ return peer;
1318
+ }
1319
+ listSupersededPrincipalRows(signingPublicKey, retainingPublicKey, retainingNodeId) {
1320
+ if (!this.peerRegistry) {
1321
+ return [];
1322
+ }
1323
+ const principalFingerprint = crypto
1324
+ .createHash("sha256")
1325
+ .update(Buffer.from(signingPublicKey, "base64"))
1326
+ .digest("hex");
1327
+ return this.peerRegistry
1328
+ .listAll()
1329
+ .filter((peer) => peer.status !== "revoked")
1330
+ .filter((peer) => peer.publicKey !== retainingPublicKey)
1331
+ .filter((peer) => !retainingNodeId || !peer.nodeId || peer.nodeId === retainingNodeId)
1332
+ .filter((peer) => this.getPeerSigningFingerprintByPublicKey(peer.publicKey) === principalFingerprint);
1333
+ }
1334
+ cleanupSupersededPrincipalRows(rows) {
1335
+ for (const peer of rows) {
1336
+ this.tearDownRelay(peer.publicKey);
1337
+ this.stopTunnelByTransportKey(peer.publicKey);
1338
+ this._peerSigningKeys.delete(peer.publicKey);
1339
+ }
1340
+ }
1341
+ getCachedOrDurablePeerSigningKey(peerPublicKey) {
1342
+ return (this._peerSigningKeys.get(peerPublicKey) ??
1343
+ this.getDurablePeerSigningKey(peerPublicKey) ??
1344
+ undefined);
1345
+ }
1346
+ async ensureNativeRuntimeAvailable() {
1347
+ const wireguard = (await import("./index.js"));
1348
+ wireguard.assertNativeAddonAvailable?.();
1349
+ }
1350
+ refreshExternalEndpointFromSharedSocket() {
1351
+ const activeSocket = this.sharedSocket;
1352
+ if (!activeSocket) {
1353
+ return;
1354
+ }
1355
+ void import("./nat.js")
1356
+ .then(({ discoverEndpoint }) => discoverEndpoint(undefined, 5000, activeSocket))
1357
+ .then((stunResult) => {
1358
+ if (!this.config || this.sharedSocket !== activeSocket) {
1359
+ return;
1360
+ }
1361
+ this.updateExternalEndpoint(stunResult.address, stunResult.port);
1362
+ })
1363
+ .catch(() => {
1364
+ // STUN failed — keep the current endpoint state. Runtime startup must
1365
+ // not block on external endpoint discovery.
1366
+ });
1367
+ }
1368
+ /**
1369
+ * Resolve a unique peer name — disambiguates if the proposed name collides
1370
+ * with our own identity or an existing peer with a different signing key.
1371
+ * Returns the original name if no collision, or "name-<8hex>" if collision.
1372
+ */
1373
+ resolveUniquePeerName(proposedName, signingPublicKey) {
1374
+ // Check collision with our own identity
1375
+ const ourName = this.getLocalDisplayNameSnapshot();
1376
+ if (ourName && proposedName === ourName) {
1377
+ return this._disambiguateName(proposedName, signingPublicKey);
1378
+ }
1379
+ // Display names are non-authoritative. If any existing row with the same
1380
+ // name belongs to a different principal, the new peer must be disambiguated.
1381
+ if (this.peerRegistry) {
1382
+ const existingPeers = this.peerRegistry.listByName(proposedName);
1383
+ const collidesWithDifferentPrincipal = existingPeers.some((existing) => existing.signingPublicKey !== null && existing.signingPublicKey !== signingPublicKey);
1384
+ if (collidesWithDifferentPrincipal) {
1385
+ return this._disambiguateName(proposedName, signingPublicKey);
1386
+ }
1387
+ }
1388
+ return proposedName;
1389
+ }
1390
+ _disambiguateName(name, signingPublicKey) {
1391
+ const fp = crypto
1392
+ .createHash("sha256")
1393
+ .update(Buffer.from(signingPublicKey, "base64"))
1394
+ .digest("hex")
1395
+ .slice(0, 8);
1396
+ return `${name}-${fp}`;
1397
+ }
1398
+ matchesPeerNodeIdClaim(claimedNodeId, peer) {
1399
+ if (!claimedNodeId) {
1400
+ return false;
1401
+ }
1402
+ const resolvedNodeId = this.resolvePeerPrincipalId(peer);
1403
+ if (!resolvedNodeId) {
1404
+ return false;
1405
+ }
1406
+ return claimedNodeId === resolvedNodeId;
1407
+ }
1408
+ matchesDeliveryAckNodeId(senderNodeId, peer) {
1409
+ if (!senderNodeId) {
1410
+ return false;
1411
+ }
1412
+ const resolvedNodeId = this.resolvePeerPrincipalId(peer);
1413
+ if (!resolvedNodeId) {
1414
+ return false;
1415
+ }
1416
+ return senderNodeId === resolvedNodeId;
1417
+ }
1418
+ /** Stage signing-key material through the runtime-owned peer identity boundary. */
1419
+ stagePendingPeerSigningKey(input) {
1420
+ const peer = this.peerRegistry?.getWithPsk(input.peerPublicKey);
1421
+ if (!peer) {
1422
+ throw new Error(`No peer row found for public key "${input.peerPublicKey}"`);
1423
+ }
1424
+ if (!peer.nodeId || peer.nodeId !== input.nodeId) {
1425
+ throw new Error(`stagePendingPeerSigningKey nodeId mismatch for public key "${input.peerPublicKey}"`);
1426
+ }
1427
+ this._peerSigningKeys.set(input.peerPublicKey, input.signingPublicKey);
1428
+ this.peerRegistry?.setSigningKeyByPublicKey(input.peerPublicKey, input.signingPublicKey);
1429
+ }
1430
+ /** Stage a direct-pair peer through the runtime-owned pending_verification transition. */
1431
+ async applyDirectPairActivation(input) {
1432
+ if (!this.peerRegistry) {
1433
+ throw new Error("No peer registry");
1434
+ }
1435
+ this.peerRegistry.upsert({
1436
+ publicKey: input.peerPublicKey,
1437
+ nodeId: input.nodeId,
1438
+ name: input.displayNameSnapshot,
1439
+ endpointHost: input.transportEndpoint.host,
1440
+ endpointPort: input.transportEndpoint.port,
1441
+ endpointRevision: input.controlEndpoint.endpointRevision,
1442
+ controlEndpointHost: input.controlEndpoint.host,
1443
+ controlEndpointPort: input.controlEndpoint.port,
1444
+ controlTlsCaFingerprint: input.controlEndpoint.tlsCaFingerprint,
1445
+ presharedKey: input.presharedKey,
1446
+ status: "pending_verification",
1447
+ signingPublicKey: input.signingPublicKey,
1448
+ });
1449
+ this.stagePendingPeerSigningKey({
1450
+ nodeId: input.nodeId,
1451
+ peerPublicKey: input.peerPublicKey,
1452
+ displayNameSnapshot: input.displayNameSnapshot,
1453
+ signingPublicKey: input.signingPublicKey,
1454
+ });
1455
+ this.reconcileDirectRouteOwnership();
1456
+ this.stopTunnel(input.nodeId);
1457
+ await this.startTunnel(input.nodeId);
1458
+ }
1459
+ /** Get all known peer signing keys keyed by durable peer principal id. */
1460
+ getAllPeerSigningKeysByPrincipal() {
1461
+ const byPrincipal = new Map();
1462
+ if (!this.peerRegistry) {
1463
+ return byPrincipal;
1464
+ }
1465
+ for (const peer of this.peerRegistry.listAll()) {
1466
+ if (this.isLocalIdentityCandidate(peer))
1467
+ continue;
1468
+ const { identityState } = (0, tools_1.derivePeerStateFromLegacyStatus)(peer);
1469
+ if (identityState === "revoked")
1470
+ continue;
1471
+ const signingPublicKey = this.getDurablePeerSigningKey(peer.publicKey);
1472
+ if (!signingPublicKey)
1473
+ continue;
1474
+ if (!peer.nodeId)
1475
+ continue;
1476
+ byPrincipal.set(peer.nodeId, signingPublicKey);
1477
+ }
1478
+ return byPrincipal;
1479
+ }
1480
+ /**
1481
+ * Activate a peer that is in pending_verification status.
1482
+ *
1483
+ * Called after the first successfully verified signed message from the peer.
1484
+ * Updates the peer's status to "active" in PeerRegistry and invokes the
1485
+ * onPeerActivated callback so the runtime can durably persist the peer's
1486
+ * verified signing identity.
1487
+ *
1488
+ * Idempotent: no-op if the peer is already active.
1489
+ */
1490
+ activatePendingPeer(nodeId) {
1491
+ if (!this.peerRegistry)
1492
+ return;
1493
+ const peer = this.peerRegistry
1494
+ .listAll()
1495
+ .find((candidate) => this.resolvePeerPrincipalId(candidate) === nodeId);
1496
+ if (!peer)
1497
+ return;
1498
+ const { identityState } = (0, tools_1.derivePeerStateFromLegacyStatus)(peer);
1499
+ if (!(0, tools_1.canCommitVerifiedPair)(identityState, { proofValid: true }))
1500
+ return;
1501
+ // Promote to active
1502
+ this.peerRegistry.upsert({
1503
+ nodeId,
1504
+ publicKey: peer.publicKey,
1505
+ name: peer.name,
1506
+ status: "active",
1507
+ });
1508
+ this.reconcileDirectRouteOwnership();
1509
+ // Notify the server to register the signing key for message auth
1510
+ const signingKey = this.getDurablePeerSigningKey(peer.publicKey);
1511
+ if (signingKey) {
1512
+ const activation = {
1513
+ nodeId,
1514
+ displayNameSnapshot: peer.name,
1515
+ signingPublicKey: signingKey,
1516
+ };
1517
+ for (const listener of this._peerActivationListeners) {
1518
+ listener(activation);
1519
+ }
1520
+ }
1521
+ }
1522
+ /** Initialize the network — ensure config exists, load peers */
1523
+ async initialize() {
1524
+ const authoritativeNodeId = this._localNodeId;
1525
+ if (!authoritativeNodeId) {
1526
+ throw new Error("NetworkManager requires an explicit runtime-owned local nodeId before initialize()");
1527
+ }
1528
+ this.config = await ensureSecureNetwork(this.ariaDir, this.arionName, {
1529
+ nodeId: authoritativeNodeId,
1530
+ });
1531
+ await this.ensureNativeRuntimeAvailable();
1532
+ this._localBootstrapCaCert = (0, bootstrap_authority_js_1.loadStoredBootstrapCaCert)(this.ariaDir);
1533
+ // Create the shared UDP socket — single port for ALL WG tunnels,
1534
+ // like the real WireGuard kernel module. `listenPort > 0` is durable
1535
+ // transport identity and must bind exactly or fail closed. `listenPort = 0`
1536
+ // is an explicit bootstrap sentinel that requests an OS-assigned port.
1537
+ if (!this.sharedSocket) {
1538
+ const dgram = await import("node:dgram");
1539
+ this.sharedSocket = dgram.createSocket("udp4");
1540
+ try {
1541
+ await new Promise((resolve, reject) => {
1542
+ const socket = this.sharedSocket;
1543
+ const handleError = (error) => {
1544
+ socket.off("error", handleError);
1545
+ reject(error);
1546
+ };
1547
+ socket.once("error", handleError);
1548
+ socket.bind(this.config.listenPort, () => {
1549
+ socket.off("error", handleError);
1550
+ resolve();
1551
+ });
1552
+ });
1553
+ }
1554
+ catch (bindErr) {
1555
+ const socket = this.sharedSocket;
1556
+ this.sharedSocket = null;
1557
+ this.sharedSocketHandlers.clear();
1558
+ if (socket) {
1559
+ await new Promise((resolve) => {
1560
+ try {
1561
+ socket.close(() => resolve());
1562
+ }
1563
+ catch {
1564
+ resolve();
1565
+ }
1566
+ });
1567
+ }
1568
+ if (bindErr.code === "EADDRINUSE" &&
1569
+ this.config.listenPort > 0) {
1570
+ throw new Error(`WireGuard listen port ${this.config.listenPort} is already in use; ` +
1571
+ "refusing to bind a different port. Stop the conflicting process or " +
1572
+ "set listenPort: 0 for explicit ephemeral bootstrap.");
1573
+ }
1574
+ throw bindErr;
1575
+ }
1576
+ if (this.config.listenPort === 0) {
1577
+ const addr = this.sharedSocket.address();
1578
+ if (typeof addr !== "string" && addr.port > 0) {
1579
+ this.config.listenPort = addr.port;
1580
+ this.persistConfig();
1581
+ }
1582
+ }
1583
+ // Route incoming packets to the first tunnel that claims them.
1584
+ this.sharedSocket.on("message", (msg, rinfo) => {
1585
+ const rejections = [];
1586
+ for (const handler of this.sharedSocketHandlers) {
1587
+ const disposition = handler(msg, rinfo);
1588
+ if (disposition.handled) {
1589
+ return;
1590
+ }
1591
+ if (disposition.outcome === "decrypt_error" ||
1592
+ disposition.outcome === "decrypt_exception") {
1593
+ rejections.push({
1594
+ peerPublicKey: disposition.peerPublicKey,
1595
+ outcome: disposition.outcome,
1596
+ errorDetail: disposition.errorDetail,
1597
+ });
1598
+ }
1599
+ }
1600
+ if (rejections.length > 0) {
1601
+ this.appendDiagnostic({
1602
+ timestamp: Date.now(),
1603
+ event: "shared_socket_packet_unhandled",
1604
+ detail: {
1605
+ sourceAddress: rinfo.address,
1606
+ sourcePort: rinfo.port,
1607
+ packetBytes: msg.length,
1608
+ messageType: msg.length > 0 ? msg[0] : null,
1609
+ rejections,
1610
+ },
1611
+ });
1612
+ }
1613
+ });
1614
+ // Re-discover external endpoint using the SHARED socket so the
1615
+ // NAT mapping matches the actual WG listening port. The initial
1616
+ // STUN in ensureSecureNetwork() used an ephemeral socket — its
1617
+ // mapping is useless after that socket was closed.
1618
+ this.refreshExternalEndpointFromSharedSocket();
1619
+ }
1620
+ // Clean up stale token claims and terminal peers from previous sessions
1621
+ this.peerRegistry?.cleanupStaleClaims();
1622
+ this.peerRegistry?.pruneTerminalPeers();
1623
+ // Restore known peer signing keys from registry
1624
+ if (this.peerRegistry) {
1625
+ this.quarantineCorruptedLocalPeerRows();
1626
+ for (const peer of this.peerRegistry.listAll()) {
1627
+ if (this.isLocalIdentityCandidate(peer))
1628
+ continue;
1629
+ const signingPublicKey = this.getDurablePeerSigningKey(peer.publicKey);
1630
+ if (!signingPublicKey)
1631
+ continue;
1632
+ this._peerSigningKeys.set(peer.publicKey, signingPublicKey);
1633
+ }
1634
+ this.reconcileDirectRouteOwnership();
1635
+ const restoreDirectPeer = async (peer) => {
1636
+ const ownership = this.getDirectRouteOwnershipForPublicKey(peer.publicKey);
1637
+ if (ownership?.ownership === "superseded") {
1638
+ this.appendDiagnostic({
1639
+ timestamp: Date.now(),
1640
+ event: "route_restore_skipped",
1641
+ peerPublicKey: peer.publicKey,
1642
+ peerNodeId: peer.nodeId,
1643
+ peerName: peer.name,
1644
+ detail: {
1645
+ ownerPublicKey: ownership.ownerPublicKey,
1646
+ routeKey: ownership.routeKey,
1647
+ },
1648
+ });
1649
+ return;
1650
+ }
1651
+ try {
1652
+ await this.startTunnelByTransportKey(peer.publicKey);
1653
+ }
1654
+ catch {
1655
+ // Best-effort restore; individual peers may be offline
1656
+ }
1657
+ };
1658
+ // Rehydrate active tunnels on startup
1659
+ for (const peer of this.peerRegistry.listActive()) {
1660
+ if (this.isLocalIdentityCandidate(peer))
1661
+ continue;
1662
+ await restoreDirectPeer(peer);
1663
+ }
1664
+ // Rehydrate tunnels for pending_verification peers.
1665
+ // These peers were paired via /pair/direct but haven't yet sent their first
1666
+ // signed message to prove identity. They need the tunnel to send that message.
1667
+ // Without this, pending_verification peers lose connectivity after restart
1668
+ // and can never activate (catch-22).
1669
+ for (const peer of this.peerRegistry.listByStatus("pending_verification")) {
1670
+ if (this.isLocalIdentityCandidate(peer))
1671
+ continue;
1672
+ await restoreDirectPeer(peer);
1673
+ }
1674
+ // Recover peers stuck in pending_tunnel (crash before durable join
1675
+ // completion or before the runtime could observe proof). Rehydrate the
1676
+ // tunnel only; never heuristically promote to active on restart.
1677
+ for (const peer of this.peerRegistry.listByStatus("pending_tunnel")) {
1678
+ if (this.isLocalIdentityCandidate(peer))
1679
+ continue;
1680
+ try {
1681
+ const ownership = this.getDirectRouteOwnershipForPublicKey(peer.publicKey);
1682
+ if (ownership?.ownership === "superseded") {
1683
+ this.appendDiagnostic({
1684
+ timestamp: Date.now(),
1685
+ event: "route_restore_skipped",
1686
+ peerPublicKey: peer.publicKey,
1687
+ peerNodeId: peer.nodeId,
1688
+ peerName: peer.name,
1689
+ detail: {
1690
+ ownerPublicKey: ownership.ownerPublicKey,
1691
+ routeKey: ownership.routeKey,
1692
+ },
1693
+ });
1694
+ continue;
1695
+ }
1696
+ await this.startTunnelByTransportKey(peer.publicKey);
1697
+ }
1698
+ catch {
1699
+ wireguardDebug(`[wireguard] WARNING: Failed to recover pending_tunnel peer "${peer.name}" — manual intervention may be required.\n`);
1700
+ }
1701
+ }
1702
+ }
1703
+ return this.config;
1704
+ }
1705
+ /** Get the current network config */
1706
+ getConfig() {
1707
+ return this.config;
1708
+ }
1709
+ get localNodeId() {
1710
+ if (this._localNodeId) {
1711
+ return this._localNodeId;
1712
+ }
1713
+ return this.config?.nodeId ? tools_1.NodeIdSchema.parse(this.config.nodeId) : undefined;
1714
+ }
1715
+ get localControlEndpoint() {
1716
+ return this._localControlEndpoint;
1717
+ }
1718
+ /** Get our own local display snapshot through the runtime boundary. */
1719
+ getLocalDisplayNameSnapshot() {
1720
+ return (this._localDisplayNameSnapshot ??
1721
+ this.config?.localDisplayNameSnapshot ??
1722
+ deriveLocalDisplayNameSnapshot(this.arionName));
1723
+ }
1724
+ setLocalDisplayNameSnapshot(name) {
1725
+ this._localDisplayNameSnapshot = name;
1726
+ if (this.config) {
1727
+ this.config.localDisplayNameSnapshot = name;
1728
+ this.persistConfig();
1729
+ }
1730
+ }
1731
+ setLocalNodeId(nodeId) {
1732
+ this._localNodeId = nodeId;
1733
+ if (this.config) {
1734
+ this.config.nodeId = nodeId;
1735
+ this.persistConfig();
1736
+ }
1737
+ }
1738
+ setLocalControlEndpoint(controlEndpoint) {
1739
+ this._localControlEndpoint = controlEndpoint;
1740
+ }
1741
+ /**
1742
+ * Update the external endpoint (called when STUN refresh detects a change).
1743
+ * Persists to disk so the new endpoint survives restarts.
1744
+ */
1745
+ updateExternalEndpoint(address, port) {
1746
+ if (!this.config)
1747
+ return;
1748
+ const changed = this.config.externalEndpoint?.address !== address ||
1749
+ this.config.externalEndpoint?.port !== port;
1750
+ this.config.externalEndpoint = { address, port };
1751
+ if (changed) {
1752
+ this.config.endpointRevision = normalizeEndpointRevision(this.config.endpointRevision) + 1;
1753
+ }
1754
+ this.persistConfig();
1755
+ }
1756
+ /**
1757
+ * Set the coordination server URL (persisted to disk).
1758
+ */
1759
+ setCoordinationUrl(url) {
1760
+ if (!this.config)
1761
+ return;
1762
+ this.config.coordinationUrl = url;
1763
+ this.persistConfig();
1764
+ }
1765
+ /**
1766
+ * Set the DERP relay server URL (persisted to disk).
1767
+ */
1768
+ setRelayUrl(url) {
1769
+ if (!this.config)
1770
+ return;
1771
+ this.config.relayUrl = url;
1772
+ this.persistConfig();
1773
+ }
1774
+ /** Persist current config to disk */
1775
+ persistConfig() {
1776
+ if (!this.config)
1777
+ return;
1778
+ const networkDir = path.join(this.ariaDir, "network");
1779
+ const configPath = path.join(networkDir, "config.json");
1780
+ try {
1781
+ fs.writeFileSync(configPath, JSON.stringify(this.config, null, 2), { mode: 0o600 });
1782
+ }
1783
+ catch {
1784
+ // Non-fatal: config is in memory even if persist fails
1785
+ }
1786
+ }
1787
+ /**
1788
+ * Compute deterministic complementary tunnel IPs from key ordering.
1789
+ *
1790
+ * Both peers must agree on who is .1 and who is .2. We use lexicographic
1791
+ * ordering of public keys: the smaller key gets 10.0.0.1, the larger gets
1792
+ * 10.0.0.2. This ensures Alice.selfIp === Bob.peerIp and vice versa,
1793
+ * making the tunnel functional in both directions.
1794
+ */
1795
+ computePeerIps(peerPublicKey) {
1796
+ if (!this.config)
1797
+ throw new Error("Network not initialized");
1798
+ const selfIsSmaller = this.config.publicKey < peerPublicKey;
1799
+ return {
1800
+ selfIp: selfIsSmaller ? 0x0a000001 : 0x0a000002,
1801
+ peerIp: selfIsSmaller ? 0x0a000002 : 0x0a000001,
1802
+ };
1803
+ }
1804
+ /** Start a SecureTunnel for a registered peer by durable node identity. */
1805
+ async startTunnel(nodeId) {
1806
+ const peerPublicKey = this.resolveCurrentDirectRoutePeerPublicKeyByNodeId(nodeId);
1807
+ if (!peerPublicKey) {
1808
+ throw new Error(`Peer ${nodeId} not found in registry or has a superseded route`);
1809
+ }
1810
+ await this.startTunnelByTransportKey(peerPublicKey);
1811
+ }
1812
+ /** Transport-internal tunnel startup by transport key. */
1813
+ async startTunnelByTransportKey(peerPublicKey) {
1814
+ this.reconcileDirectRouteOwnership();
1815
+ this.ensureCurrentDirectRouteOwner(peerPublicKey);
1816
+ if (this.tunnels.has(peerPublicKey))
1817
+ return; // Already running — idempotent
1818
+ const pendingStart = this.pendingTunnelStarts.get(peerPublicKey);
1819
+ if (pendingStart) {
1820
+ await pendingStart;
1821
+ return;
1822
+ }
1823
+ if (!this.config)
1824
+ throw new Error("Network not initialized");
1825
+ this.assertRemotePeerIdentity({ publicKey: peerPublicKey }, "Tunnel peer");
1826
+ if (this.tunnels.size + this.pendingTunnelStarts.size >= NetworkManager.MAX_PEERS) {
1827
+ throw new Error(`Tunnel limit reached (${NetworkManager.MAX_PEERS})`);
1828
+ }
1829
+ const peerInfo = this.peerRegistry?.getWithPsk(peerPublicKey);
1830
+ if (!peerInfo)
1831
+ throw new Error(`Peer ${peerPublicKey} not found in registry`);
1832
+ this.assertRemotePeerIdentity({
1833
+ publicKey: peerPublicKey,
1834
+ signingPublicKey: peerInfo.signingPublicKey ?? null,
1835
+ name: peerInfo.name,
1836
+ }, "Tunnel peer");
1837
+ const startPromise = (async () => {
1838
+ this.appendDiagnostic({
1839
+ timestamp: Date.now(),
1840
+ event: "tunnel_start_requested",
1841
+ peerPublicKey,
1842
+ peerNodeId: peerInfo.nodeId,
1843
+ peerName: peerInfo.name,
1844
+ detail: {
1845
+ endpointHost: peerInfo.endpointHost,
1846
+ endpointPort: peerInfo.endpointPort,
1847
+ },
1848
+ });
1849
+ const ips = this.computePeerIps(peerPublicKey);
1850
+ const tunnel = new resilient_tunnel_js_1.ResilientTunnel({
1851
+ privateKey: this.config.privateKey,
1852
+ peerPublicKey: peerPublicKey,
1853
+ presharedKey: peerInfo.presharedKey ?? undefined,
1854
+ keepalive: 25,
1855
+ // Use shared socket (single-port model) when available, else random port
1856
+ listenPort: this.sharedSocket ? undefined : 0,
1857
+ externalSocket: this.externalSocketInterface,
1858
+ peerHost: peerInfo.endpointHost ?? undefined,
1859
+ peerPort: peerInfo.endpointPort ?? undefined,
1860
+ tunnelSrcIp: ips.selfIp,
1861
+ tunnelDstIp: ips.peerIp,
1862
+ });
1863
+ tunnel.on("handshake", () => {
1864
+ if (this.peerRegistry) {
1865
+ this.peerRegistry.updateHandshake(peerPublicKey);
1866
+ this.appendDiagnostic({
1867
+ timestamp: Date.now(),
1868
+ event: "handshake_db_updated",
1869
+ peerPublicKey,
1870
+ peerNodeId: peerInfo.nodeId,
1871
+ peerName: peerInfo.name,
1872
+ detail: {
1873
+ reason: "secure_tunnel_handshake",
1874
+ },
1875
+ });
1876
+ }
1877
+ });
1878
+ await tunnel.start();
1879
+ this.appendDiagnostic({
1880
+ timestamp: Date.now(),
1881
+ event: "tunnel_start_completed",
1882
+ peerPublicKey,
1883
+ peerNodeId: peerInfo.nodeId,
1884
+ peerName: peerInfo.name,
1885
+ detail: {
1886
+ resilientState: tunnel.getState(),
1887
+ isActive: tunnel.isActive,
1888
+ },
1889
+ });
1890
+ if (this.canceledTunnelStarts.delete(peerPublicKey)) {
1891
+ tunnel.stop();
1892
+ return;
1893
+ }
1894
+ this.tunnels.set(peerPublicKey, tunnel);
1895
+ this.appendDiagnostic({
1896
+ timestamp: Date.now(),
1897
+ event: "tunnel_registered",
1898
+ peerPublicKey,
1899
+ peerNodeId: peerInfo.nodeId,
1900
+ peerName: peerInfo.name,
1901
+ detail: {
1902
+ resilientState: tunnel.getState(),
1903
+ isActive: tunnel.isActive,
1904
+ },
1905
+ });
1906
+ {
1907
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
1908
+ if (nodeId) {
1909
+ const bootstrapTransport = {
1910
+ sendPlaintext: (data) => tunnel.sendPlaintext(data),
1911
+ routeBootstrapOnly: true,
1912
+ };
1913
+ for (const listener of this._transportListeners) {
1914
+ listener.onRouteBootstrapAvailable?.(peerInfo.name, bootstrapTransport, nodeId);
1915
+ }
1916
+ }
1917
+ }
1918
+ // Handle tunnel errors — without this listener, ResilientTunnel's error
1919
+ // re-emit causes an uncaught exception that crashes the process.
1920
+ // The ResilientTunnel internally handles reconnection on error.
1921
+ tunnel.on("error", (err) => {
1922
+ this.appendDiagnostic({
1923
+ timestamp: Date.now(),
1924
+ event: "resilient_tunnel_error",
1925
+ peerPublicKey,
1926
+ peerNodeId: peerInfo.nodeId,
1927
+ peerName: peerInfo.name,
1928
+ detail: { error: err.message },
1929
+ });
1930
+ });
1931
+ // Monitor tunnel health — log state transitions and notify listeners only
1932
+ // after proven remote activity promotes the tunnel to CONNECTED.
1933
+ tunnel.on("stateChange", (newState, prevState) => {
1934
+ this.appendDiagnostic({
1935
+ timestamp: Date.now(),
1936
+ event: "resilient_state_change",
1937
+ peerPublicKey,
1938
+ peerNodeId: peerInfo.nodeId,
1939
+ peerName: peerInfo.name,
1940
+ detail: {
1941
+ prevState,
1942
+ newState,
1943
+ },
1944
+ });
1945
+ // State transitions captured by appendDiagnostic above.
1946
+ if (newState === "connected" &&
1947
+ (prevState === "reconnecting" ||
1948
+ prevState === "handshaking" ||
1949
+ prevState === "connecting")) {
1950
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
1951
+ if (nodeId) {
1952
+ for (const listener of this._transportListeners) {
1953
+ listener.onTransportUp?.(peerInfo.name, tunnel, nodeId);
1954
+ }
1955
+ this.appendDiagnostic({
1956
+ timestamp: Date.now(),
1957
+ event: "transport_up_emitted",
1958
+ peerPublicKey,
1959
+ peerNodeId: nodeId,
1960
+ peerName: peerInfo.name,
1961
+ detail: {
1962
+ reason: `${prevState}_to_connected`,
1963
+ },
1964
+ });
1965
+ }
1966
+ }
1967
+ });
1968
+ // When a tunnel reports dead (max reconnection attempts exhausted),
1969
+ // attempt DERP relay fallback if configured, otherwise clean up.
1970
+ tunnel.on("dead", () => {
1971
+ wireguardDebug(`[wireguard] Tunnel ${peerPublicKey.slice(0, 8)}... is DEAD`);
1972
+ this.tunnels.delete(peerPublicKey);
1973
+ // Notify listeners that the direct tunnel is torn down
1974
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
1975
+ if (nodeId) {
1976
+ for (const listener of this._transportListeners) {
1977
+ listener.onTransportDown?.(peerInfo.name, nodeId);
1978
+ }
1979
+ }
1980
+ // Attempt DERP relay fallback if relay URL is configured
1981
+ const localDisplayNameSnapshot = this.getLocalDisplayNameSnapshot();
1982
+ if (this.config?.relayUrl && this.config.signingPrivateKey && localDisplayNameSnapshot) {
1983
+ const peerInfo = this.peerRegistry?.getWithPsk(peerPublicKey);
1984
+ const identityState = peerInfo
1985
+ ? (0, tools_1.derivePeerStateFromLegacyStatus)(peerInfo).identityState
1986
+ : undefined;
1987
+ if (peerInfo && identityState && (0, tools_1.canHeartbeat)(identityState)) {
1988
+ wireguardDebug(` — falling back to DERP relay for ${peerInfo.name}\n`);
1989
+ // Lazy-import DerpRelay to avoid circular dependency at module level
1990
+ void import("./derp-relay.js")
1991
+ .then(({ DerpRelay }) => {
1992
+ const localNodeId = this.resolvePeerPrincipalId({
1993
+ name: localDisplayNameSnapshot,
1994
+ publicKey: this.config.publicKey,
1995
+ });
1996
+ const targetNodeId = this.resolvePeerPrincipalId(peerInfo);
1997
+ if (!localNodeId || !targetNodeId) {
1998
+ wireguardDebug(`[wireguard] DERP relay requires durable node identities for ${peerPublicKey.slice(0, 8)}...\n`);
1999
+ return;
2000
+ }
2001
+ const relay = new DerpRelay({
2002
+ relayUrl: this.config.relayUrl,
2003
+ nodeId: localNodeId,
2004
+ displayNameSnapshot: localDisplayNameSnapshot,
2005
+ signingPrivateKey: this.config.signingPrivateKey,
2006
+ signingPublicKey: this.config.signingPublicKey,
2007
+ targetNodeId,
2008
+ });
2009
+ relay.on("plaintext", (data, from, fromNodeId) => {
2010
+ const parsedFromNodeId = tools_1.NodeIdSchema.safeParse(fromNodeId);
2011
+ if (!parsedFromNodeId.success ||
2012
+ !this.matchesPeerNodeIdClaim(parsedFromNodeId.data, peerInfo)) {
2013
+ wireguardDebug(`[wireguard] Relay sender principal mismatch for ${peerPublicKey.slice(0, 8)}... expected "${this.resolvePeerPrincipalId(peerInfo) ?? peerInfo.name}", got "${String(fromNodeId)}"\n`);
2014
+ return;
2015
+ }
2016
+ // Route relay-received data through the same handler as tunnel data
2017
+ try {
2018
+ const parsed = JSON.parse(data.toString());
2019
+ const envelope = tools_1.RuntimeIngressEnvelopeSchema.safeParse(parsed);
2020
+ if (envelope.success && "deliveryAck" in envelope.data) {
2021
+ const ack = envelope.data.deliveryAck;
2022
+ // Delivery acks are now keyed by durable node ids, not display names.
2023
+ // The authenticated relay/tunnel channel proves the remote transport
2024
+ // peer, and the runtime outbox/mailbox layer validates sender/recipient
2025
+ // node ids against pending durable receipts before settling them.
2026
+ for (const listener of this._deliveryAckListeners) {
2027
+ listener(ack);
2028
+ }
2029
+ return;
2030
+ }
2031
+ if (parsed.ariaMessage) {
2032
+ const claimedSenderNodeId = parsed.ariaMessage?.sender?.id;
2033
+ const parsedSenderNodeId = tools_1.NodeIdSchema.safeParse(claimedSenderNodeId);
2034
+ if (!parsedSenderNodeId.success ||
2035
+ !this.matchesPeerNodeIdClaim(parsedSenderNodeId.data, peerInfo)) {
2036
+ wireguardDebug(`[wireguard] Dropped relay message with sender principal mismatch for ${peerPublicKey.slice(0, 8)}... expected "${this.resolvePeerPrincipalId(peerInfo) ?? peerInfo.name}", got "${String(claimedSenderNodeId)}"\n`);
2037
+ return;
2038
+ }
2039
+ for (const ml of this._messageListeners) {
2040
+ ml(parsed.ariaMessage);
2041
+ }
2042
+ }
2043
+ }
2044
+ catch {
2045
+ // Not JSON — forward raw data via relay handler
2046
+ this.onRelayDataReceived?.(data, from);
2047
+ }
2048
+ });
2049
+ void relay
2050
+ .connect()
2051
+ .then(() => {
2052
+ void this.setupRelayFallback(peerPublicKey, {
2053
+ send: (data) => relay.send(data),
2054
+ disconnect: () => relay.disconnect(),
2055
+ });
2056
+ })
2057
+ .catch(() => {
2058
+ // Relay connection failed — peer is truly unreachable
2059
+ wireguardDebug(`[wireguard] DERP relay fallback also failed for ${peerPublicKey.slice(0, 8)}...\n`);
2060
+ });
2061
+ })
2062
+ .catch(() => {
2063
+ // DerpRelay import failed (ws not available)
2064
+ });
2065
+ return;
2066
+ }
2067
+ }
2068
+ wireguardDebug(` — removing from active tunnels\n`);
2069
+ });
2070
+ tunnel.on("queueFlushPartialFailure", (info) => {
2071
+ wireguardDebug(`[wireguard] Tunnel ${peerPublicKey.slice(0, 8)}... flush: ${info.failed}/${info.total} messages failed\n`);
2072
+ });
2073
+ tunnel.on("queueOverflow", (info) => {
2074
+ wireguardDebug(`[wireguard] Tunnel ${peerPublicKey.slice(0, 8)}... queue overflow (${info.reason}), dropped=${info.dropped}\n`);
2075
+ });
2076
+ tunnel.on("messagesExpired", (info) => {
2077
+ wireguardDebug(`[wireguard] Tunnel ${peerPublicKey.slice(0, 8)}... ${info.count} queued messages expired (TTL)\n`);
2078
+ });
2079
+ // Listen for messages received through the tunnel.
2080
+ // Two message types are handled:
2081
+ // 1. { ariaMessage: ... } — peer-to-peer AriaMessage (Transport 3)
2082
+ // 2. { joinRequest: ... } — peer join-back credentials (sent via tunnel, not HTTP)
2083
+ tunnel.on("plaintext", (data) => {
2084
+ try {
2085
+ const parsed = JSON.parse(data.toString());
2086
+ const envelope = tools_1.RuntimeIngressEnvelopeSchema.safeParse(parsed);
2087
+ if (envelope.success) {
2088
+ if ("deliveryAck" in envelope.data) {
2089
+ const ack = envelope.data.deliveryAck;
2090
+ // Delivery ack sender/recipient identity is validated at the durable
2091
+ // mailbox/outbox boundary using NodeIds. The transport layer no
2092
+ // longer enforces stale display-name checks here.
2093
+ for (const listener of this._deliveryAckListeners) {
2094
+ listener(ack);
2095
+ }
2096
+ return;
2097
+ }
2098
+ if ("ariaMessage" in envelope.data) {
2099
+ const msg = envelope.data.ariaMessage;
2100
+ const claimedSenderNodeId = typeof msg === "object" && msg !== null
2101
+ ? msg.sender?.id
2102
+ : undefined;
2103
+ const parsedSenderNodeId = tools_1.NodeIdSchema.safeParse(claimedSenderNodeId);
2104
+ if (!parsedSenderNodeId.success ||
2105
+ !this.matchesPeerNodeIdClaim(parsedSenderNodeId.data, peerInfo)) {
2106
+ wireguardDebug(`[wireguard] Dropped tunnel message with sender principal mismatch for ${peerPublicKey.slice(0, 8)}... expected "${this.resolvePeerPrincipalId(peerInfo) ?? peerInfo.name}", got "${String(claimedSenderNodeId)}"\n`);
2107
+ return;
2108
+ }
2109
+ // Validate required AriaMessage fields before dispatch
2110
+ if (msg &&
2111
+ typeof msg === "object" &&
2112
+ "id" in msg &&
2113
+ typeof msg.id === "string" &&
2114
+ "sender" in msg &&
2115
+ "recipient" in msg &&
2116
+ "type" in msg &&
2117
+ typeof msg.type === "string" &&
2118
+ "content" in msg &&
2119
+ typeof msg.content === "string") {
2120
+ for (const ml of this._messageListeners) {
2121
+ ml(msg);
2122
+ }
2123
+ }
2124
+ return;
2125
+ }
2126
+ if ("joinRequest" in envelope.data) {
2127
+ const jr = envelope.data.joinRequest;
2128
+ if (!jr.controlEndpoint) {
2129
+ return;
2130
+ }
2131
+ // Peer is sending join-back credentials through the tunnel.
2132
+ // Process asynchronously — tunnel event handlers are synchronous.
2133
+ void this.completeJoin({
2134
+ nodeId: jr.nodeId,
2135
+ principalFingerprint: signingKeyFingerprintFromPublicKey(jr.signingPublicKey),
2136
+ peerPublicKey: jr.peerPublicKey,
2137
+ peerSigningKey: jr.signingPublicKey,
2138
+ peerTransportEndpoint: jr.transportEndpoint,
2139
+ peerControlEndpoint: jr.controlEndpoint,
2140
+ displayNameSnapshot: jr.displayNameSnapshot,
2141
+ inviteTokenNonce: jr.inviteTokenNonce,
2142
+ }).catch(() => {
2143
+ // Non-fatal: join-back failed but tunnel is already active
2144
+ });
2145
+ return;
2146
+ }
2147
+ }
2148
+ if (parsed.controlRequest) {
2149
+ // WG Tunnel Control Protocol — peer-to-peer control operations
2150
+ // through the encrypted tunnel (no HTTPS needed post-pairing).
2151
+ void this.handleControlRequest(parsed.controlRequest, peerPublicKey, peerInfo, tunnel);
2152
+ }
2153
+ else if (parsed.controlResponse) {
2154
+ // Response to a control request we sent
2155
+ this.handleControlResponse(parsed.controlResponse, peerInfo);
2156
+ }
2157
+ }
2158
+ catch {
2159
+ // Not valid JSON — ignore (non-message tunnel traffic)
2160
+ }
2161
+ });
2162
+ })();
2163
+ this.pendingTunnelStarts.set(peerPublicKey, startPromise);
2164
+ try {
2165
+ await startPromise;
2166
+ }
2167
+ finally {
2168
+ if (this.pendingTunnelStarts.get(peerPublicKey) === startPromise) {
2169
+ this.pendingTunnelStarts.delete(peerPublicKey);
2170
+ }
2171
+ }
2172
+ // NOTE: We do NOT overwrite endpointPort here. The WireGuard tunnel port
2173
+ // is NOT the pinned mutating control endpoint. Transport and control
2174
+ // endpoints are persisted independently to avoid restart-time drift.
2175
+ }
2176
+ /** Stop and remove a tunnel for a peer by durable node identity. */
2177
+ stopTunnel(nodeId) {
2178
+ const peer = this.resolvePeerByNodeId(nodeId, { includeRevoked: true });
2179
+ if (!peer) {
2180
+ return;
2181
+ }
2182
+ this.stopTunnelByTransportKey(peer.publicKey);
2183
+ }
2184
+ /** Transport-internal tunnel shutdown by transport key. */
2185
+ stopTunnelByTransportKey(peerPublicKey) {
2186
+ const tunnel = this.tunnels.get(peerPublicKey);
2187
+ if (!tunnel) {
2188
+ if (this.pendingTunnelStarts.has(peerPublicKey)) {
2189
+ this.canceledTunnelStarts.add(peerPublicKey);
2190
+ }
2191
+ return;
2192
+ }
2193
+ const peerInfo = this.peerRegistry?.getWithPsk(peerPublicKey);
2194
+ tunnel.stop();
2195
+ this.tunnels.delete(peerPublicKey);
2196
+ if (peerInfo) {
2197
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
2198
+ if (nodeId) {
2199
+ for (const listener of this._transportListeners) {
2200
+ listener.onTransportDown?.(peerInfo.name, nodeId);
2201
+ }
2202
+ }
2203
+ }
2204
+ }
2205
+ /** Compatibility surface for the typed network manager ref: revoke by durable peer principal. */
2206
+ revokePeer(nodeId) {
2207
+ return this.revoke(nodeId);
2208
+ }
2209
+ /** Get a tunnel by durable peer identity (for testing/inspection). */
2210
+ getTunnel(nodeId) {
2211
+ const peerPublicKey = this.resolveCurrentDirectRoutePeerPublicKeyByNodeId(nodeId);
2212
+ return peerPublicKey ? this.tunnels.get(peerPublicKey) : undefined;
2213
+ }
2214
+ /** Get the number of active tunnels */
2215
+ get activeTunnelCount() {
2216
+ return this.tunnels.size;
2217
+ }
2218
+ /** Generate an invite token for a new peer */
2219
+ resolveLocalInviteEndpoint() {
2220
+ if (!this.config)
2221
+ return null;
2222
+ const nets = require("node:os").networkInterfaces();
2223
+ for (const addrs of Object.values(nets)) {
2224
+ for (const addr of addrs ?? []) {
2225
+ if (!addr.internal && addr.family === "IPv4") {
2226
+ return { address: addr.address, port: this.config.listenPort };
2227
+ }
2228
+ }
2229
+ }
2230
+ return null;
2231
+ }
2232
+ invite(displayNameSnapshot, options) {
2233
+ if (!this.config)
2234
+ throw new Error("Network not initialized");
2235
+ let endpoint = this.config.externalEndpoint;
2236
+ if (!endpoint) {
2237
+ // STUN failed (symmetric NAT, no internet, etc.) — fall back to local network address.
2238
+ // This enables LAN pairing even without STUN. WAN peers need a coordination relay.
2239
+ endpoint = this.resolveLocalInviteEndpoint() ?? undefined;
2240
+ if (!endpoint) {
2241
+ throw new Error("Cannot create invite — no reachable network address. STUN failed and no local network interface found.");
2242
+ }
2243
+ }
2244
+ if (this.peerRegistry) {
2245
+ const activeCount = this.peerRegistry.activeCount();
2246
+ if (activeCount >= NetworkManager.MAX_PEERS) {
2247
+ throw new Error(`Peer limit reached (${NetworkManager.MAX_PEERS}). Revoke inactive peers first.`);
2248
+ }
2249
+ }
2250
+ // Also check tunnel count — prevents zombie peers with no tunnel
2251
+ if (this.tunnels.size >= NetworkManager.MAX_PEERS) {
2252
+ throw new Error(`Tunnel limit reached (${NetworkManager.MAX_PEERS}).`);
2253
+ }
2254
+ const inviteLabel = normalizeInviteLabel(displayNameSnapshot);
2255
+ const inviteOptions = typeof options === "number"
2256
+ ? { durationMs: options }
2257
+ : {
2258
+ durationMs: options?.durationMs,
2259
+ controlEndpoint: options?.controlEndpoint,
2260
+ transportHostOverride: options?.transportHostOverride,
2261
+ nodeId: options?.nodeId ? tools_1.NodeIdSchema.parse(options.nodeId) : undefined,
2262
+ caCert: options?.caCert,
2263
+ };
2264
+ if (typeof options === "object" && options !== null) {
2265
+ assertNoUnexpectedActorKeys(options, ["durationMs", "controlEndpoint", "transportHostOverride", "nodeId", "caCert"], "invite");
2266
+ }
2267
+ const inviterControlEndpoint = inviteOptions.controlEndpoint ?? this._localControlEndpoint;
2268
+ if (!inviterControlEndpoint) {
2269
+ throw new Error("invite requires the local control endpoint advertisement");
2270
+ }
2271
+ const bootstrapCaCert = inviteOptions.caCert ?? this._localBootstrapCaCert;
2272
+ if (!bootstrapCaCert?.trim()) {
2273
+ throw new Error("invite requires bootstrap CA authority");
2274
+ }
2275
+ const localNodeId = this.localNodeId;
2276
+ if (!localNodeId) {
2277
+ throw new Error("invite requires an explicit runtime-owned local nodeId");
2278
+ }
2279
+ const localPrincipalFingerprint = trySigningKeyFingerprintFromPublicKey(this.config.signingPublicKey?.trim() ?? "");
2280
+ if (!localPrincipalFingerprint) {
2281
+ throw new Error("invite requires an explicit runtime-owned local signing principal");
2282
+ }
2283
+ if (inviterControlEndpoint.tlsServerIdentity?.trim() !== localPrincipalFingerprint) {
2284
+ throw new Error("invite requires control endpoint tlsServerIdentity to match the signing principal fingerprint");
2285
+ }
2286
+ // Use config.listenPort (the shared socket's bound port) as the advertised
2287
+ // WG endpoint port. The STUN-discovered externalEndpoint.port is from an
2288
+ // ephemeral socket and may not match the shared socket's NAT mapping.
2289
+ // For public IPs (no NAT), listenPort IS the correct port directly.
2290
+ // For NAT, the STUN fix (Phase 1 nat.ts) will make externalEndpoint.port
2291
+ // reflect the shared socket's mapping.
2292
+ const { token, psk } = createInviteToken({
2293
+ leaderPublicKey: this.config.publicKey,
2294
+ nodeId: localNodeId,
2295
+ audienceNodeId: inviteOptions.nodeId,
2296
+ leaderDisplayNameSnapshot: this.getLocalDisplayNameSnapshot() ?? this.arionName,
2297
+ host: inviteOptions.transportHostOverride?.trim() || endpoint.address,
2298
+ port: this.config.listenPort,
2299
+ controlEndpoint: inviterControlEndpoint,
2300
+ displayNameSnapshot: inviteLabel,
2301
+ durationMs: inviteOptions.durationMs,
2302
+ signingPublicKey: this.config.signingPublicKey,
2303
+ caCert: bootstrapCaCert,
2304
+ coordinationUrl: this.config.coordinationUrl,
2305
+ signingPrivateKey: this.config.signingPrivateKey,
2306
+ });
2307
+ const invite = decodeInviteToken(token);
2308
+ // Register peer as pending in DB
2309
+ if (this.peerRegistry) {
2310
+ const pendingAudienceNodeId = inviteOptions.nodeId;
2311
+ this.peerRegistry.upsert({
2312
+ ...(pendingAudienceNodeId ? { nodeId: pendingAudienceNodeId } : {}),
2313
+ publicKey: `pending-${crypto.randomBytes(8).toString("hex")}`,
2314
+ name: inviteLabel ?? buildPendingInvitePlaceholderName(invite.tokenNonce),
2315
+ presharedKey: psk,
2316
+ inviteToken: invite.tokenNonce,
2317
+ status: "pending",
2318
+ });
2319
+ }
2320
+ return { token, psk };
2321
+ }
2322
+ /** Accept an invite from another ARIA instance */
2323
+ async acceptInvite(token, options) {
2324
+ // MAX_PEERS guard — prevent exceeding peer limit via invite acceptance
2325
+ const activeCount = this.peerRegistry?.listActive().length ?? 0;
2326
+ if (activeCount >= NetworkManager.MAX_PEERS) {
2327
+ throw new Error(`Peer limit reached (${NetworkManager.MAX_PEERS}). Revoke inactive peers first.`);
2328
+ }
2329
+ const invite = decodeInviteToken(token);
2330
+ const displayNameSnapshot = typeof options === "string" ? options : options?.displayNameSnapshot;
2331
+ const localControlEndpoint = typeof options === "string" ? undefined : options?.controlEndpoint;
2332
+ const expectedLeaderNodeId = typeof options === "string"
2333
+ ? undefined
2334
+ : options?.nodeId
2335
+ ? tools_1.NodeIdSchema.parse(options.nodeId)
2336
+ : undefined;
2337
+ // Check expiry
2338
+ if (invite.expiresAt > 0 && Date.now() > invite.expiresAt) {
2339
+ throw new Error("Invite token has expired");
2340
+ }
2341
+ // P0-3: Atomic single-use enforcement — claim token in a SQLite transaction
2342
+ // before any async operations. Eliminates the race window where two concurrent
2343
+ // acceptInvite calls could both pass the check before either marks it consumed.
2344
+ if (this.peerRegistry) {
2345
+ if (!this.peerRegistry.claimToken(invite.tokenNonce)) {
2346
+ throw new Error("Invite token has already been used");
2347
+ }
2348
+ }
2349
+ try {
2350
+ if (!this.config) {
2351
+ await this.initialize();
2352
+ }
2353
+ const localConfig = this.config;
2354
+ if (!localConfig) {
2355
+ throw new Error("Network not initialized");
2356
+ }
2357
+ const localNodeId = localConfig.nodeId
2358
+ ? tools_1.NodeIdSchema.parse(localConfig.nodeId)
2359
+ : this.localNodeId;
2360
+ if (!localNodeId) {
2361
+ throw new Error("acceptInvite requires an explicit runtime-owned local nodeId");
2362
+ }
2363
+ if (invite.audienceNodeId && invite.audienceNodeId !== localNodeId) {
2364
+ throw new Error("Invite token is not addressed to this peer");
2365
+ }
2366
+ if (expectedLeaderNodeId && invite.nodeId !== expectedLeaderNodeId) {
2367
+ throw new Error("Invite token nodeId does not match expected leader nodeId");
2368
+ }
2369
+ const inviterNodeId = invite.nodeId?.trim();
2370
+ if (!inviterNodeId) {
2371
+ throw new Error("Invite token missing inviter durable nodeId");
2372
+ }
2373
+ const inviterLeaderNodeId = tools_1.NodeIdSchema.parse(inviterNodeId);
2374
+ const resolvedDisplayNameSnapshot = invite.leaderDisplayNameSnapshot?.trim() || displayNameSnapshot?.trim() || "leader";
2375
+ this.assertRemotePeerIdentity({
2376
+ publicKey: invite.publicKey,
2377
+ signingPublicKey: invite.signingPublicKey ?? null,
2378
+ name: resolvedDisplayNameSnapshot,
2379
+ }, "Invite peer");
2380
+ const advertisedInviterControl = invite.controlEndpoint;
2381
+ if (!advertisedInviterControl) {
2382
+ throw new Error("Invite token missing inviter control endpoint advertisement");
2383
+ }
2384
+ if (!localControlEndpoint) {
2385
+ throw new Error("acceptInvite requires the local control endpoint advertisement");
2386
+ }
2387
+ (0, tools_1.assertSupportedNetworkRuntimeProtocolVersion)(advertisedInviterControl.protocolVersion, "invite control endpoint");
2388
+ (0, tools_1.assertSupportedNetworkRuntimeProtocolVersion)(localControlEndpoint.protocolVersion, "join control endpoint");
2389
+ const peerInfo = {
2390
+ publicKey: invite.publicKey,
2391
+ nodeId: inviterLeaderNodeId,
2392
+ name: resolvedDisplayNameSnapshot,
2393
+ endpointHost: invite.host,
2394
+ endpointPort: invite.port,
2395
+ endpointRevision: advertisedInviterControl.endpointRevision ?? 0,
2396
+ controlEndpointHost: advertisedInviterControl.host,
2397
+ controlEndpointPort: advertisedInviterControl.port,
2398
+ controlTlsCaFingerprint: advertisedInviterControl.tlsCaFingerprint,
2399
+ controlEndpoint: {
2400
+ host: advertisedInviterControl.host,
2401
+ port: advertisedInviterControl.port,
2402
+ tlsCaFingerprint: advertisedInviterControl.tlsCaFingerprint,
2403
+ tlsServerIdentity: advertisedInviterControl.tlsServerIdentity,
2404
+ protocolVersion: advertisedInviterControl.protocolVersion,
2405
+ endpointRevision: advertisedInviterControl.endpointRevision ?? 0,
2406
+ },
2407
+ status: "pending_tunnel",
2408
+ lastHandshake: null,
2409
+ createdAt: Date.now(),
2410
+ updatedAt: Date.now(),
2411
+ signingPublicKey: invite.signingPublicKey ?? null,
2412
+ };
2413
+ const supersededPrincipalRows = invite.signingPublicKey
2414
+ ? this.listSupersededPrincipalRows(invite.signingPublicKey, invite.publicKey, inviterLeaderNodeId)
2415
+ : [];
2416
+ if (this.peerRegistry) {
2417
+ const db = this.peerRegistry["db"];
2418
+ db.transaction(() => {
2419
+ for (const superseded of supersededPrincipalRows) {
2420
+ this.peerRegistry.delete(superseded.publicKey);
2421
+ }
2422
+ if (!this.peerRegistry) {
2423
+ throw new Error("Peer registry unavailable during invite acceptance");
2424
+ }
2425
+ this.peerRegistry.upsert({
2426
+ nodeId: inviterLeaderNodeId,
2427
+ publicKey: invite.publicKey,
2428
+ name: resolvedDisplayNameSnapshot,
2429
+ status: "pending_tunnel",
2430
+ endpointHost: invite.host,
2431
+ endpointPort: invite.port,
2432
+ endpointRevision: advertisedInviterControl.endpointRevision ?? 0,
2433
+ controlEndpointHost: advertisedInviterControl.host,
2434
+ controlEndpointPort: advertisedInviterControl.port,
2435
+ controlTlsCaFingerprint: advertisedInviterControl.tlsCaFingerprint,
2436
+ presharedKey: invite.psk,
2437
+ inviteToken: invite.tokenNonce,
2438
+ signingPublicKey: invite.signingPublicKey,
2439
+ });
2440
+ })();
2441
+ }
2442
+ this.cleanupSupersededPrincipalRows(supersededPrincipalRows);
2443
+ // Register leader's signing key if present in the invite token.
2444
+ // This lets us verify signatures on messages from the leader.
2445
+ if (invite.signingPublicKey) {
2446
+ this.stagePendingPeerSigningKey({
2447
+ nodeId: inviterLeaderNodeId,
2448
+ peerPublicKey: invite.publicKey,
2449
+ displayNameSnapshot: resolvedDisplayNameSnapshot,
2450
+ signingPublicKey: invite.signingPublicKey,
2451
+ });
2452
+ }
2453
+ this.reconcileDirectRouteOwnership();
2454
+ // Store coordination URL from invite for peer discovery.
2455
+ // This enables the accepting peer to discover other peers in the network.
2456
+ if (invite.coordinationUrl && this.config && !this.config.coordinationUrl) {
2457
+ this.config.coordinationUrl = invite.coordinationUrl;
2458
+ this.persistConfig();
2459
+ }
2460
+ // Start tunnel for the newly accepted peer.
2461
+ await this.startTunnelByTransportKey(invite.publicKey);
2462
+ // Send our credentials back to the leader through the WireGuard tunnel.
2463
+ // This avoids sending credentials over plaintext HTTP to the leader's public IP.
2464
+ // The tunnel is already established at this point (startTunnel above).
2465
+ if (this.config) {
2466
+ const tunnel = this.tunnels.get(invite.publicKey);
2467
+ let joinConfirmed = false;
2468
+ const localControlTlsIdentity = localControlEndpoint.tlsServerIdentity?.trim() || undefined;
2469
+ if (!localControlTlsIdentity) {
2470
+ throw new Error("acceptInvite join propagation requires an explicit tlsServerIdentity on the local control endpoint");
2471
+ }
2472
+ const localPrincipalFingerprint = typeof this.config.signingPublicKey === "string" &&
2473
+ this.config.signingPublicKey.trim().length > 0
2474
+ ? crypto
2475
+ .createHash("sha256")
2476
+ .update(Buffer.from(this.config.signingPublicKey, "base64"))
2477
+ .digest("hex")
2478
+ : undefined;
2479
+ if (!localPrincipalFingerprint) {
2480
+ throw new Error("acceptInvite join propagation requires an explicit runtime-owned local signing principal");
2481
+ }
2482
+ // Bootstrap join mutates durable peer state, so it is addressed by the
2483
+ // runtime-owned node principal while TLS still pins to the signing fingerprint.
2484
+ if (localControlTlsIdentity !== localPrincipalFingerprint) {
2485
+ throw new Error("acceptInvite join propagation requires control endpoint tlsServerIdentity to match the signing principal fingerprint");
2486
+ }
2487
+ const joinTransportEndpoint = this.config.externalEndpoint ?? this.resolveLocalInviteEndpoint();
2488
+ if (!joinTransportEndpoint) {
2489
+ throw new Error("acceptInvite join propagation requires a reachable transport endpoint");
2490
+ }
2491
+ const joinRequest = tools_1.JoinRequestSchema.parse({
2492
+ nodeId: localNodeId,
2493
+ principalFingerprint: tools_1.PrincipalFingerprintSchema.parse(localPrincipalFingerprint),
2494
+ protocolVersion: localControlEndpoint.protocolVersion,
2495
+ peerPublicKey: this.config.publicKey,
2496
+ signingPublicKey: this.config.signingPublicKey,
2497
+ transportEndpoint: {
2498
+ host: joinTransportEndpoint.address,
2499
+ port: this.config.listenPort,
2500
+ },
2501
+ controlEndpoint: localControlEndpoint,
2502
+ displayNameSnapshot: invite.displayNameSnapshot,
2503
+ inviteTokenNonce: invite.tokenNonce,
2504
+ });
2505
+ // Try tunnel first as a transport optimization only.
2506
+ // Queueing plaintext locally is not proof that the remote peer received
2507
+ // or durably committed our join credentials.
2508
+ if (tunnel) {
2509
+ try {
2510
+ tunnel.sendPlaintext(Buffer.from(JSON.stringify({
2511
+ joinRequest,
2512
+ })));
2513
+ }
2514
+ catch {
2515
+ // Tunnel send failed — fall back to HTTP
2516
+ }
2517
+ }
2518
+ // Always require a confirmed bootstrap join response before treating the
2519
+ // invite accept as successful. The tunnel send above may help delivery,
2520
+ // but it is only a best-effort optimization until there is an explicit
2521
+ // tunnel-level acknowledgement protocol.
2522
+ const bootstrapCaCert = invite.caCert?.trim();
2523
+ if (bootstrapCaCert) {
2524
+ const expectedTlsIdentity = advertisedInviterControl.tlsServerIdentity?.trim();
2525
+ if (!expectedTlsIdentity) {
2526
+ throw new Error("Invite control endpoint missing tlsServerIdentity for bootstrap TLS verification");
2527
+ }
2528
+ try {
2529
+ const challengeUrl = `https://${advertisedInviterControl.host}:${advertisedInviterControl.port}/api/v1/join/challenge`;
2530
+ const challengeRes = await (0, bootstrap_tls_js_1.bootstrapTlsRequest)(challengeUrl, {
2531
+ caCert: bootstrapCaCert,
2532
+ expectedTlsIdentity,
2533
+ });
2534
+ if (challengeRes.status !== 200) {
2535
+ throw new Error(`Join challenge failed: ${challengeRes.status}`);
2536
+ }
2537
+ const challengePayload = JSON.parse(challengeRes.body);
2538
+ if (!challengePayload.challenge || typeof challengePayload.challenge !== "string") {
2539
+ throw new Error("Join challenge missing challenge payload");
2540
+ }
2541
+ const proofOfWork = solveJoinProofOfWork(challengePayload.challenge, challengePayload.difficulty ?? 4);
2542
+ const joinUrl = `https://${advertisedInviterControl.host}:${advertisedInviterControl.port}/api/v1/join`;
2543
+ const joinBody = JSON.stringify({
2544
+ ...joinRequest,
2545
+ proofOfWork,
2546
+ });
2547
+ const joinRes = await (0, bootstrap_tls_js_1.bootstrapTlsRequest)(joinUrl, {
2548
+ method: "POST",
2549
+ body: joinBody,
2550
+ caCert: bootstrapCaCert,
2551
+ expectedTlsIdentity,
2552
+ headers: {
2553
+ "Content-Type": "application/json",
2554
+ "Content-Length": Buffer.byteLength(joinBody).toString(),
2555
+ },
2556
+ });
2557
+ if (joinRes.status !== 200) {
2558
+ throw new Error(`Join HTTP fallback failed: ${joinRes.status}`);
2559
+ }
2560
+ persistTrustedPeerCa(this.ariaDir, bootstrapCaCert);
2561
+ joinConfirmed = true;
2562
+ }
2563
+ catch (httpErr) {
2564
+ // Without a confirmed join response, the accept path must fail
2565
+ // closed. A queued tunnel write is not a completion signal.
2566
+ wireguardDebug(`[wireguard] HTTP join confirmation failed: ${httpErr instanceof Error ? httpErr.message : String(httpErr)}\n`);
2567
+ }
2568
+ }
2569
+ if (!joinConfirmed && invite.coordinationUrl?.trim()) {
2570
+ try {
2571
+ const relayUrl = new URL("/api/v1/invite-relay/join", invite.coordinationUrl).toString();
2572
+ const relayResponse = await fetch(relayUrl, {
2573
+ method: "POST",
2574
+ headers: {
2575
+ "Content-Type": "application/json",
2576
+ },
2577
+ body: JSON.stringify({
2578
+ inviteToken: token,
2579
+ joinRequest,
2580
+ }),
2581
+ });
2582
+ const relayPayload = (await relayResponse.json());
2583
+ if (!relayResponse.ok || relayPayload.joined !== true) {
2584
+ throw new Error(relayPayload.error ??
2585
+ `Invite relay fallback failed with status ${relayResponse.status}`);
2586
+ }
2587
+ if (bootstrapCaCert) {
2588
+ persistTrustedPeerCa(this.ariaDir, bootstrapCaCert);
2589
+ }
2590
+ joinConfirmed = true;
2591
+ }
2592
+ catch (relayErr) {
2593
+ wireguardDebug(`[wireguard] Invite relay fallback failed: ${relayErr instanceof Error ? relayErr.message : String(relayErr)}\n`);
2594
+ }
2595
+ }
2596
+ if (!joinConfirmed) {
2597
+ if (!this.revoke(invite.nodeId)) {
2598
+ this.stopTunnelByTransportKey(invite.publicKey);
2599
+ this._peerSigningKeys.delete(invite.publicKey);
2600
+ this.peerRegistry?.revoke(invite.publicKey);
2601
+ }
2602
+ throw new Error("Failed to confirm join credentials with leader via bootstrap HTTP join");
2603
+ }
2604
+ }
2605
+ // Accepting an invite only proves that we validated the inviter's signed
2606
+ // token and attempted to propagate our join credentials back. It does not
2607
+ // prove that the round-trip join completed or that a verified signed
2608
+ // message has been observed. Keep the accepter-side peer pending_tunnel
2609
+ // until proof-driven activation occurs through verified ingress.
2610
+ if (this.peerRegistry) {
2611
+ this.peerRegistry.upsert({
2612
+ nodeId: inviterLeaderNodeId,
2613
+ publicKey: invite.publicKey,
2614
+ name: resolvedDisplayNameSnapshot,
2615
+ status: "pending_tunnel",
2616
+ });
2617
+ }
2618
+ peerInfo.status = "pending_tunnel";
2619
+ return peerInfo;
2620
+ }
2621
+ catch (err) {
2622
+ // P0-3: Release the atomic token claim so the token can be retried
2623
+ this.peerRegistry?.releaseToken(invite.tokenNonce);
2624
+ throw err;
2625
+ }
2626
+ }
2627
+ /**
2628
+ * Complete the join process — upgrade a pending peer to active with real credentials.
2629
+ *
2630
+ * Called by the leader when a peer POSTs its credentials back after accepting an invite.
2631
+ * Removes the pending placeholder entry and registers the real peer with its actual
2632
+ * WireGuard public key, signing key, and endpoint.
2633
+ */
2634
+ async completeJoin(params) {
2635
+ if (!this.peerRegistry)
2636
+ throw new Error("No peer registry");
2637
+ if (!params.inviteTokenNonce || typeof params.inviteTokenNonce !== "string") {
2638
+ throw new Error("Join request missing invite token nonce");
2639
+ }
2640
+ // Resolve pending invite by token nonce first (immutable identity anchor).
2641
+ let pending = this.peerRegistry.getPendingByInviteToken(params.inviteTokenNonce);
2642
+ if (!pending || pending.status !== "pending" || !pending.publicKey.startsWith("pending-")) {
2643
+ throw new Error(`No pending invite found for token nonce "${params.inviteTokenNonce}"`);
2644
+ }
2645
+ const pendingWithPsk = this.peerRegistry.getWithPsk(pending.publicKey);
2646
+ const pendingToken = pendingWithPsk?.inviteToken;
2647
+ if (!pendingToken || pendingToken !== params.inviteTokenNonce) {
2648
+ throw new Error("Join request token nonce does not match pending invite");
2649
+ }
2650
+ if (pending.nodeId && params.nodeId !== pending.nodeId) {
2651
+ throw new Error("Join request nodeId mismatch for pending invite");
2652
+ }
2653
+ // THEN check peer limit (only for valid invites)
2654
+ const activeCount = this.peerRegistry.activeCount();
2655
+ if (activeCount >= NetworkManager.MAX_PEERS) {
2656
+ throw new Error(`Peer limit reached (${NetworkManager.MAX_PEERS}). Revoke inactive peers first.`);
2657
+ }
2658
+ assertValidPeerPublicKeyB64(params.peerPublicKey, "Join peer public key");
2659
+ assertValidSigningPublicKeyB64(params.peerSigningKey, "Join peer signing key");
2660
+ const transportEndpointResult = tools_1.TransportEndpointAdvertisementSchema.safeParse(params.peerTransportEndpoint);
2661
+ if (!transportEndpointResult.success) {
2662
+ throw new Error("Join peer transport endpoint is invalid");
2663
+ }
2664
+ const controlEndpointResult = tools_1.ControlEndpointAdvertisementSchema.safeParse(params.peerControlEndpoint);
2665
+ if (!controlEndpointResult.success) {
2666
+ throw new Error("Join peer control endpoint is invalid");
2667
+ }
2668
+ const authoritativeDisplayName = params.displayNameSnapshot?.trim() || pending.nodeId || params.nodeId;
2669
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(authoritativeDisplayName)) {
2670
+ throw new Error("Join peer name must match ^[a-zA-Z0-9_-]{1,64}$");
2671
+ }
2672
+ const peerTransportEndpoint = transportEndpointResult.data;
2673
+ const peerControlEndpoint = controlEndpointResult.data;
2674
+ const peerPrincipalFingerprint = trySigningKeyFingerprintFromPublicKey(params.peerSigningKey);
2675
+ if (!peerPrincipalFingerprint) {
2676
+ throw new Error("Join peer signing key could not derive a principal fingerprint");
2677
+ }
2678
+ if (peerPrincipalFingerprint !== params.principalFingerprint) {
2679
+ throw new Error("Join peer principal fingerprint does not match the signing key");
2680
+ }
2681
+ if (peerControlEndpoint.tlsServerIdentity !== peerPrincipalFingerprint) {
2682
+ throw new Error("Join peer control endpoint tlsServerIdentity must match the signing principal fingerprint");
2683
+ }
2684
+ // Use the invite-bound display snapshot as the single authority here.
2685
+ // The join request may carry a snapshot for diagnostics, but it must never
2686
+ // decide which pending invite row is completed or rename the invite target.
2687
+ const effectiveName = this.resolveUniquePeerName(authoritativeDisplayName, params.peerSigningKey);
2688
+ this.assertRemotePeerIdentity({
2689
+ publicKey: params.peerPublicKey,
2690
+ signingPublicKey: params.peerSigningKey,
2691
+ name: effectiveName,
2692
+ }, "Join peer");
2693
+ // Atomically remove the pending placeholder and register the real peer as
2694
+ // pending_verification. Join completion transports credentials and endpoint
2695
+ // advertisements, but it is not identity proof. Activation only happens
2696
+ // after the first successfully verified signed message.
2697
+ const psk = pendingWithPsk?.presharedKey ?? undefined;
2698
+ const db = this.peerRegistry["db"];
2699
+ const supersededPrincipalRows = this.listSupersededPrincipalRows(params.peerSigningKey, params.peerPublicKey, params.nodeId);
2700
+ db.transaction(() => {
2701
+ for (const superseded of supersededPrincipalRows) {
2702
+ this.peerRegistry.delete(superseded.publicKey);
2703
+ }
2704
+ this.peerRegistry.delete(pending.publicKey);
2705
+ this.peerRegistry.upsert({
2706
+ nodeId: params.nodeId,
2707
+ publicKey: params.peerPublicKey,
2708
+ name: effectiveName,
2709
+ endpointHost: peerTransportEndpoint.host,
2710
+ endpointPort: peerTransportEndpoint.port,
2711
+ endpointRevision: peerControlEndpoint?.endpointRevision ?? 0,
2712
+ controlEndpointHost: peerControlEndpoint?.host,
2713
+ controlEndpointPort: peerControlEndpoint?.port,
2714
+ controlTlsCaFingerprint: peerControlEndpoint?.tlsCaFingerprint,
2715
+ presharedKey: psk,
2716
+ status: "pending_verification",
2717
+ signingPublicKey: params.peerSigningKey,
2718
+ inviteToken: params.inviteTokenNonce,
2719
+ });
2720
+ })();
2721
+ this.cleanupSupersededPrincipalRows(supersededPrincipalRows);
2722
+ // Keep in-memory map and DB row synchronized for verification on restart
2723
+ this.stagePendingPeerSigningKey({
2724
+ nodeId: params.nodeId,
2725
+ peerPublicKey: params.peerPublicKey,
2726
+ displayNameSnapshot: effectiveName,
2727
+ signingPublicKey: params.peerSigningKey,
2728
+ });
2729
+ this.reconcileDirectRouteOwnership();
2730
+ // Start a tunnel to the peer so the first signed message can arrive.
2731
+ // Do not promote to active here — activation is proof-driven.
2732
+ await this.startTunnelByTransportKey(params.peerPublicKey);
2733
+ return { effectiveName };
2734
+ }
2735
+ /** Record a heartbeat from a peer principal (updates last_handshake timestamp) */
2736
+ heartbeat(nodeId) {
2737
+ if (!this.peerRegistry)
2738
+ return false;
2739
+ const peerPublicKey = this.resolveUniqueLivePeerPublicKeyByNodeId(nodeId);
2740
+ const peer = peerPublicKey ? this.peerRegistry.getWithPsk(peerPublicKey) : undefined;
2741
+ if (!peer)
2742
+ return false;
2743
+ const { identityState } = (0, tools_1.derivePeerStateFromLegacyStatus)(peer);
2744
+ if (!(0, tools_1.canHeartbeat)(identityState))
2745
+ return false;
2746
+ this.peerRegistry.updateHandshakeByNodeId(nodeId);
2747
+ return true;
2748
+ }
2749
+ /**
2750
+ * Apply a register/reconcile mutation by durable peer identity.
2751
+ *
2752
+ * This centralizes the peer-state checks that used to be reimplemented
2753
+ * route-by-route using mutable display names.
2754
+ */
2755
+ applyPeerRegistration(input) {
2756
+ if (!this.peerRegistry) {
2757
+ return { registered: false, errorCode: "not_found" };
2758
+ }
2759
+ const peer = this.resolvePeerByNodeId(input.nodeId, { includeRevoked: true }) ?? undefined;
2760
+ if (!peer) {
2761
+ return { registered: false, errorCode: "not_found" };
2762
+ }
2763
+ const peerNodeId = input.nodeId;
2764
+ const { identityState } = (0, tools_1.derivePeerStateFromLegacyStatus)(peer);
2765
+ if (!(0, tools_1.canRefreshEndpoint)(identityState) && !(0, tools_1.canHeartbeat)(identityState)) {
2766
+ if (peer.status === "active" || peer.status === "pending_verification") {
2767
+ throw new Error(`Invariant violation: applyPeerRegistration blocked registerable peer state ${peer.status}`);
2768
+ }
2769
+ return { registered: false, errorCode: peer.status };
2770
+ }
2771
+ let endpointUpdated = false;
2772
+ let endpointRevision = peer.endpointRevision ?? 0;
2773
+ if (input.endpointHost && input.endpointPort) {
2774
+ const requestedEndpointRevision = input.endpointRevision;
2775
+ if (typeof requestedEndpointRevision !== "number" ||
2776
+ !Number.isInteger(requestedEndpointRevision) ||
2777
+ requestedEndpointRevision < 0) {
2778
+ return { registered: false, errorCode: "missing_endpoint_revision" };
2779
+ }
2780
+ const endpointMutation = this.applyEndpointProjectionByTransportKey(peer.publicKey, input.endpointHost, input.endpointPort, requestedEndpointRevision);
2781
+ if (endpointMutation.errorCode &&
2782
+ endpointMutation.errorCode !== "stale_revision" &&
2783
+ endpointMutation.errorCode !== "conflicting_revision") {
2784
+ return { registered: false, errorCode: endpointMutation.errorCode };
2785
+ }
2786
+ endpointUpdated = endpointMutation.updated;
2787
+ endpointRevision = endpointMutation.endpointRevision;
2788
+ }
2789
+ if (endpointUpdated) {
2790
+ this.reconcileDirectRouteOwnership();
2791
+ }
2792
+ const heartbeatUpdated = this.heartbeat(peerNodeId);
2793
+ return {
2794
+ registered: true,
2795
+ nodeId: peerNodeId,
2796
+ ...(peer.name ? { displayNameSnapshot: peer.name } : {}),
2797
+ peerStatus: peer.status,
2798
+ lastSeen: Date.now(),
2799
+ heartbeatUpdated,
2800
+ endpointUpdated,
2801
+ endpointRevision,
2802
+ };
2803
+ }
2804
+ /** Revoke a peer's access */
2805
+ revoke(nodeId) {
2806
+ if (!this.peerRegistry)
2807
+ return false;
2808
+ const peer = this.peerRegistry.getByNodeId(nodeId);
2809
+ if (!peer)
2810
+ return false;
2811
+ // Clean up relay fallback if active (prevents WebSocket + timer leak)
2812
+ this.tearDownRelay(peer.publicKey);
2813
+ // Stop the direct tunnel
2814
+ this.stopTunnelByTransportKey(peer.publicKey);
2815
+ // Evict signing key (prevents memory leak from historical peers)
2816
+ this._peerSigningKeys.delete(peer.publicKey);
2817
+ this.peerRegistry.revokeByNodeId(nodeId);
2818
+ return true;
2819
+ }
2820
+ applyPeerRevocation(input) {
2821
+ if (!this.peerRegistry) {
2822
+ return { revoked: false, errorCode: "not_found" };
2823
+ }
2824
+ const peer = this.peerRegistry.getByNodeId(input.nodeId);
2825
+ if (!peer) {
2826
+ return { revoked: false, errorCode: "not_found" };
2827
+ }
2828
+ if (!peer.nodeId) {
2829
+ return { revoked: false, errorCode: "not_found" };
2830
+ }
2831
+ const peerNodeId = peer.nodeId;
2832
+ const signingPublicKey = peer.signingPublicKey ?? this.getDurablePeerSigningKey(peer.publicKey);
2833
+ const fingerprint = input.fingerprint
2834
+ ? input.fingerprint
2835
+ : signingPublicKey
2836
+ ? signingKeyFingerprintFromPublicKey(signingPublicKey)
2837
+ : undefined;
2838
+ if (!fingerprint) {
2839
+ return { revoked: false, errorCode: "principal_unresolved" };
2840
+ }
2841
+ const revokedAt = Date.now();
2842
+ this.revoke(input.nodeId);
2843
+ return {
2844
+ revoked: true,
2845
+ nodeId: peerNodeId,
2846
+ displayNameSnapshot: peer.name,
2847
+ fingerprint: tools_1.PrincipalFingerprintSchema.parse(fingerprint),
2848
+ revokedAt,
2849
+ };
2850
+ }
2851
+ /** List all peers */
2852
+ listPeers() {
2853
+ if (!this.peerRegistry)
2854
+ return [];
2855
+ const routeOwnership = this.getDirectRouteOwnershipSnapshot();
2856
+ return this.peerRegistry
2857
+ .listAll()
2858
+ .filter((peer) => !this.isLocalIdentityCandidate(peer))
2859
+ .map((peer) => {
2860
+ const routeDecision = routeOwnership.get(peer.publicKey);
2861
+ const ownerPeer = routeDecision && routeDecision.ownerPublicKey !== peer.publicKey
2862
+ ? this.peerRegistry?.getWithPsk(routeDecision.ownerPublicKey)
2863
+ : undefined;
2864
+ const routeOwnerNodeId = ownerPeer ? this.resolvePeerPrincipalId(ownerPeer) : null;
2865
+ const tunnel = routeDecision?.ownership === "superseded" ? undefined : this.tunnels.get(peer.publicKey);
2866
+ const sessionState = routeDecision?.ownership === "superseded"
2867
+ ? "none"
2868
+ : tunnel
2869
+ ? tunnel.getState()
2870
+ : "none";
2871
+ const membershipStatus = peer.status;
2872
+ const deliveryReadiness = membershipStatus === "revoked" || routeDecision?.ownership === "superseded"
2873
+ ? "cannot_address"
2874
+ : sessionState === "connected"
2875
+ ? "can_send_now"
2876
+ : peer.nodeId
2877
+ ? "can_queue_only"
2878
+ : "cannot_address";
2879
+ return {
2880
+ ...peer,
2881
+ membershipStatus,
2882
+ routeOwnership: routeDecision?.ownership ?? "current",
2883
+ routeOwnerNodeId,
2884
+ sessionState,
2885
+ deliveryReadiness,
2886
+ };
2887
+ });
2888
+ }
2889
+ listPendingInvites() {
2890
+ if (!this.peerRegistry)
2891
+ return [];
2892
+ return this.peerRegistry
2893
+ .listByStatus("pending")
2894
+ .map((peer) => {
2895
+ const peerWithPsk = this.peerRegistry?.getWithPsk(peer.publicKey);
2896
+ const inviteId = peerWithPsk?.inviteToken ?? undefined;
2897
+ if (!inviteId) {
2898
+ return null;
2899
+ }
2900
+ return {
2901
+ inviteId,
2902
+ inviteLabel: extractPendingInviteLabel(peer.name),
2903
+ createdAt: peer.createdAt,
2904
+ expiresAt: null,
2905
+ };
2906
+ })
2907
+ .filter((invite) => invite !== null);
2908
+ }
2909
+ cancelInvite(inviteId) {
2910
+ if (!this.peerRegistry)
2911
+ return false;
2912
+ const pendingInvite = this.peerRegistry.getPendingByInviteToken(inviteId);
2913
+ if (!pendingInvite) {
2914
+ return false;
2915
+ }
2916
+ this.peerRegistry.revoke(pendingInvite.publicKey);
2917
+ return true;
2918
+ }
2919
+ /**
2920
+ * Update a peer's endpoint in the registry by durable peer identity.
2921
+ *
2922
+ * Called when a peer's STUN-discovered IP changes and is reported via
2923
+ * the coordination server's POST /register endpoint.
2924
+ */
2925
+ updatePeerEndpoint(nodeId, host, port, endpointRevision) {
2926
+ this.applyPeerRepair({
2927
+ nodeId,
2928
+ endpointHost: host,
2929
+ endpointPort: port,
2930
+ endpointRevision,
2931
+ });
2932
+ }
2933
+ /**
2934
+ * Apply a transport-only repair mutation by durable peer identity.
2935
+ *
2936
+ * This is the authoritative mutation boundary for operator-driven endpoint
2937
+ * repair. It shares the same identity-state gate as register/discovery but
2938
+ * does not manufacture a heartbeat side effect.
2939
+ */
2940
+ applyPeerRepair(input) {
2941
+ if (!this.peerRegistry) {
2942
+ return { repaired: false, errorCode: "not_found" };
2943
+ }
2944
+ const peer = this.resolvePeerByNodeId(input.nodeId, { includeRevoked: true }) ?? undefined;
2945
+ if (!peer) {
2946
+ return { repaired: false, errorCode: "not_found" };
2947
+ }
2948
+ if (!peer.nodeId) {
2949
+ return { repaired: false, errorCode: "not_found" };
2950
+ }
2951
+ const peerNodeId = peer.nodeId;
2952
+ const { identityState } = (0, tools_1.derivePeerStateFromLegacyStatus)(peer);
2953
+ if (!(0, tools_1.canRefreshEndpoint)(identityState)) {
2954
+ if (peer.status === "active" || peer.status === "pending_verification") {
2955
+ throw new Error(`Invariant violation: applyPeerRepair blocked repairable peer state ${peer.status}`);
2956
+ }
2957
+ return { repaired: false, errorCode: peer.status };
2958
+ }
2959
+ if (!Number.isInteger(input.endpointRevision) || (input.endpointRevision ?? -1) < 0) {
2960
+ return { repaired: false, errorCode: "missing_endpoint_revision" };
2961
+ }
2962
+ const endpointMutation = this.applyEndpointProjectionByTransportKey(peer.publicKey, input.endpointHost, input.endpointPort, input.endpointRevision);
2963
+ if (endpointMutation.errorCode) {
2964
+ return { repaired: false, errorCode: endpointMutation.errorCode };
2965
+ }
2966
+ if (endpointMutation.updated) {
2967
+ this.reconcileDirectRouteOwnership();
2968
+ }
2969
+ return {
2970
+ repaired: true,
2971
+ nodeId: peerNodeId,
2972
+ ...(peer.name ? { displayNameSnapshot: peer.name } : {}),
2973
+ peerStatus: peer.status,
2974
+ endpointUpdated: endpointMutation.updated,
2975
+ endpointHost: input.endpointHost,
2976
+ endpointPort: input.endpointPort,
2977
+ endpointRevision: endpointMutation.endpointRevision,
2978
+ };
2979
+ }
2980
+ /**
2981
+ * Apply a transport endpoint projection after a NodeId-owned caller has already
2982
+ * resolved the authoritative remote actor. Transport keys stay internal here.
2983
+ */
2984
+ applyEndpointProjectionByTransportKey(peerPublicKey, host, port, endpointRevision) {
2985
+ if (!this.peerRegistry) {
2986
+ return { updated: false, endpointRevision: 0 };
2987
+ }
2988
+ assertValidEndpointHost(host, "Peer endpoint host");
2989
+ assertValidPort(port, "Peer endpoint port");
2990
+ const peer = this.peerRegistry.getByPublicKey(peerPublicKey);
2991
+ if (!peer) {
2992
+ return { updated: false, endpointRevision: 0 };
2993
+ }
2994
+ const { identityState } = (0, tools_1.derivePeerStateFromLegacyStatus)(peer);
2995
+ if (!(0, tools_1.canRefreshEndpoint)(identityState)) {
2996
+ return {
2997
+ updated: false,
2998
+ endpointRevision: normalizeEndpointRevision(peer.endpointRevision),
2999
+ };
3000
+ }
3001
+ if (!peer.nodeId) {
3002
+ return {
3003
+ updated: false,
3004
+ endpointRevision: normalizeEndpointRevision(peer.endpointRevision),
3005
+ };
3006
+ }
3007
+ if (!Number.isInteger(endpointRevision) || endpointRevision < 0) {
3008
+ throw new Error("Peer endpoint revision must be a non-negative integer");
3009
+ }
3010
+ const revisionDecision = evaluateEndpointRevision({
3011
+ currentHost: peer.endpointHost,
3012
+ currentPort: peer.endpointPort,
3013
+ currentRevision: peer.endpointRevision,
3014
+ nextHost: host,
3015
+ nextPort: port,
3016
+ nextRevision: endpointRevision,
3017
+ });
3018
+ if (revisionDecision.kind === "stale") {
3019
+ return {
3020
+ updated: false,
3021
+ endpointRevision: revisionDecision.currentRevision,
3022
+ errorCode: "stale_revision",
3023
+ };
3024
+ }
3025
+ if (revisionDecision.kind === "conflict") {
3026
+ return {
3027
+ updated: false,
3028
+ endpointRevision: revisionDecision.currentRevision,
3029
+ errorCode: "conflicting_revision",
3030
+ };
3031
+ }
3032
+ const endpointChanged = peer.endpointHost !== host || peer.endpointPort !== port;
3033
+ // Update live tunnel endpoint first. Persisting a DB endpoint that the
3034
+ // live tunnel rejected creates split-brain state across restart boundaries.
3035
+ // Applies to active and pending_verification peers: the latter still need
3036
+ // fresh endpoints to deliver their first signed proof and recover after restart.
3037
+ const tunnel = this.tunnels.get(peer.publicKey);
3038
+ if (tunnel) {
3039
+ const inner = tunnel.getInnerTunnel();
3040
+ if (inner) {
3041
+ inner.setPeerEndpoint(host, port);
3042
+ }
3043
+ }
3044
+ // Persist only after live transport accepts the endpoint update.
3045
+ this.peerRegistry.upsert({
3046
+ nodeId: peer.nodeId,
3047
+ publicKey: peer.publicKey,
3048
+ name: peer.name,
3049
+ endpointHost: host,
3050
+ endpointPort: port,
3051
+ endpointRevision: revisionDecision.revision,
3052
+ });
3053
+ return {
3054
+ updated: revisionDecision.kind === "apply" ? endpointChanged : false,
3055
+ endpointRevision: revisionDecision.revision,
3056
+ };
3057
+ }
3058
+ /**
3059
+ * Get the relay transport for a peer (if using DERP relay fallback).
3060
+ * Returns the same { send } interface as tunnel transports for Mailbox compatibility.
3061
+ */
3062
+ getRelayTransport(peerPublicKey) {
3063
+ return this.relayTransports.get(peerPublicKey);
3064
+ }
3065
+ /**
3066
+ * Set up a DERP relay fallback for a peer.
3067
+ *
3068
+ * Called when a direct tunnel dies (MAX_RECONNECT_ATTEMPTS exhausted).
3069
+ * Creates a DerpRelay connection and registers it as a transport.
3070
+ * Periodically attempts to re-establish the direct tunnel via lightweight probe.
3071
+ */
3072
+ async setupRelayFallback(peerPublicKey, relay) {
3073
+ this.relayTransports.set(peerPublicKey, relay);
3074
+ this.relayUpgradeAttempts.set(peerPublicKey, 0);
3075
+ // Notify listeners (Mailbox registration)
3076
+ const peerInfo = this.peerRegistry?.getWithPsk(peerPublicKey);
3077
+ if (peerInfo) {
3078
+ const relayTransport = { sendPlaintext: (data) => relay.send(data) };
3079
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
3080
+ if (nodeId) {
3081
+ for (const listener of this._transportListeners) {
3082
+ listener.onTransportUp?.(peerInfo.name, relayTransport, nodeId);
3083
+ }
3084
+ }
3085
+ }
3086
+ // Schedule first upgrade attempt
3087
+ this.scheduleUpgradeProbe(peerPublicKey);
3088
+ }
3089
+ /**
3090
+ * Schedule a lightweight probe to check if direct tunnel is possible.
3091
+ * Uses exponential backoff: 5min → 10min → 20min → cap at 30min.
3092
+ * On success, tears down relay and restores direct tunnel.
3093
+ */
3094
+ scheduleUpgradeProbe(peerPublicKey) {
3095
+ const attempts = this.relayUpgradeAttempts.get(peerPublicKey) ?? 0;
3096
+ const baseInterval = NetworkManager.RELAY_UPGRADE_INTERVAL_MS; // 5 min
3097
+ const maxInterval = 30 * 60_000; // 30 min cap
3098
+ const interval = Math.min(baseInterval * Math.pow(2, attempts), maxInterval);
3099
+ const timer = setTimeout(async () => {
3100
+ this.relayUpgradeTimers.delete(peerPublicKey);
3101
+ // Skip if relay was already torn down
3102
+ if (!this.relayTransports.has(peerPublicKey))
3103
+ return;
3104
+ if (this.getDirectRouteOwnershipForPublicKey(peerPublicKey)?.ownership === "superseded") {
3105
+ this.tearDownRelay(peerPublicKey);
3106
+ return;
3107
+ }
3108
+ const probeSuccess = await this.probeDirectConnection(peerPublicKey);
3109
+ if (probeSuccess) {
3110
+ // Direct path is alive — restore full tunnel
3111
+ try {
3112
+ await this.startTunnelByTransportKey(peerPublicKey);
3113
+ const tunnel = this.tunnels.get(peerPublicKey);
3114
+ if (tunnel && tunnel.getState() === "connected") {
3115
+ this.tearDownRelay(peerPublicKey);
3116
+ wireguardDebug(`[wireguard] Direct tunnel restored for ${peerPublicKey.slice(0, 8)}... — relay disconnected\n`);
3117
+ return;
3118
+ }
3119
+ }
3120
+ catch {
3121
+ // startTunnel failed despite probe success — unusual, keep relay
3122
+ }
3123
+ }
3124
+ // Probe failed — increment backoff and schedule next attempt
3125
+ this.relayUpgradeAttempts.set(peerPublicKey, attempts + 1);
3126
+ this.scheduleUpgradeProbe(peerPublicKey);
3127
+ }, interval);
3128
+ this.relayUpgradeTimers.set(peerPublicKey, timer);
3129
+ }
3130
+ /**
3131
+ * Lightweight probe: create a temporary SecureTunnel, wait 5s for handshake.
3132
+ * Returns true if peer is directly reachable, false otherwise.
3133
+ * Does NOT create a persistent tunnel — just checks connectivity.
3134
+ */
3135
+ async probeDirectConnection(peerPublicKey) {
3136
+ if (!this.config)
3137
+ return false;
3138
+ if (this.getDirectRouteOwnershipForPublicKey(peerPublicKey)?.ownership === "superseded") {
3139
+ return false;
3140
+ }
3141
+ const peerInfo = this.peerRegistry?.getWithPsk(peerPublicKey);
3142
+ if (!peerInfo || !peerInfo.endpointHost || !peerInfo.endpointPort)
3143
+ return false;
3144
+ const ips = this.computePeerIps(peerPublicKey);
3145
+ const probe = new tunnel_js_1.SecureTunnel({
3146
+ privateKey: this.config.privateKey,
3147
+ peerPublicKey,
3148
+ presharedKey: peerInfo.presharedKey ?? undefined,
3149
+ keepalive: 25,
3150
+ listenPort: 0,
3151
+ peerHost: peerInfo.endpointHost ?? undefined,
3152
+ peerPort: peerInfo.endpointPort ?? undefined,
3153
+ tunnelSrcIp: ips.selfIp,
3154
+ tunnelDstIp: ips.peerIp,
3155
+ });
3156
+ return new Promise((resolve) => {
3157
+ const timeout = setTimeout(() => {
3158
+ probe.stop();
3159
+ resolve(false);
3160
+ }, 5_000);
3161
+ probe.on("handshake", () => {
3162
+ clearTimeout(timeout);
3163
+ probe.stop();
3164
+ resolve(true);
3165
+ });
3166
+ probe.on("error", () => {
3167
+ clearTimeout(timeout);
3168
+ probe.stop();
3169
+ resolve(false);
3170
+ });
3171
+ probe.start().catch(() => {
3172
+ clearTimeout(timeout);
3173
+ resolve(false);
3174
+ });
3175
+ });
3176
+ }
3177
+ /** Tear down relay for a peer and notify listeners */
3178
+ tearDownRelay(peerPublicKey) {
3179
+ const relay = this.relayTransports.get(peerPublicKey);
3180
+ if (!relay)
3181
+ return;
3182
+ relay.disconnect();
3183
+ this.relayTransports.delete(peerPublicKey);
3184
+ this.relayUpgradeAttempts.delete(peerPublicKey);
3185
+ const timer = this.relayUpgradeTimers.get(peerPublicKey);
3186
+ if (timer) {
3187
+ clearTimeout(timer);
3188
+ this.relayUpgradeTimers.delete(peerPublicKey);
3189
+ }
3190
+ // Notify listeners (Mailbox unregistration)
3191
+ const peerInfo = this.peerRegistry?.getWithPsk(peerPublicKey);
3192
+ if (peerInfo) {
3193
+ const nodeId = this.resolvePeerPrincipalId(peerInfo);
3194
+ if (nodeId) {
3195
+ for (const listener of this._transportListeners) {
3196
+ listener.onTransportDown?.(peerInfo.name, nodeId);
3197
+ }
3198
+ }
3199
+ }
3200
+ }
3201
+ /** Reset relay upgrade backoff (call when STUN detects endpoint change) */
3202
+ resetRelayUpgradeBackoff() {
3203
+ for (const [peerPublicKey] of this.relayTransports) {
3204
+ this.relayUpgradeAttempts.set(peerPublicKey, 0);
3205
+ // Re-schedule with reset backoff
3206
+ const timer = this.relayUpgradeTimers.get(peerPublicKey);
3207
+ if (timer) {
3208
+ clearTimeout(timer);
3209
+ this.relayUpgradeTimers.delete(peerPublicKey);
3210
+ }
3211
+ this.scheduleUpgradeProbe(peerPublicKey);
3212
+ }
3213
+ }
3214
+ /** Shut down all tunnels, relays, and clean up resources */
3215
+ async shutdown() {
3216
+ for (const key of this.pendingTunnelStarts.keys()) {
3217
+ this.canceledTunnelStarts.add(key);
3218
+ }
3219
+ for (const [key, tunnel] of this.tunnels) {
3220
+ tunnel.stop();
3221
+ }
3222
+ this.tunnels.clear();
3223
+ // Close the shared UDP socket and WAIT for the OS to release the port.
3224
+ // dgram.Socket.close() is async — without awaiting the callback, the port
3225
+ // stays bound and the next daemon start fails with EADDRINUSE.
3226
+ if (this.sharedSocket) {
3227
+ const socket = this.sharedSocket;
3228
+ this.sharedSocket = null;
3229
+ this.sharedSocketHandlers.clear();
3230
+ await new Promise((resolve) => {
3231
+ try {
3232
+ socket.close(() => resolve());
3233
+ }
3234
+ catch {
3235
+ resolve(); // Socket already closed
3236
+ }
3237
+ // Safety timeout — don't hang shutdown if close callback never fires
3238
+ setTimeout(resolve, 2000);
3239
+ });
3240
+ }
3241
+ // Clean up relay connections and notify listeners
3242
+ for (const [key] of this.relayTransports) {
3243
+ this.tearDownRelay(key);
3244
+ }
3245
+ this.relayTransports.clear();
3246
+ this.relayUpgradeAttempts.clear();
3247
+ // Clear any remaining upgrade timers
3248
+ for (const [key, timer] of this.relayUpgradeTimers) {
3249
+ clearTimeout(timer);
3250
+ }
3251
+ this.relayUpgradeTimers.clear();
3252
+ }
3253
+ /** Get network status */
3254
+ status() {
3255
+ const peers = this.listPeers();
3256
+ return {
3257
+ configured: this.config !== null,
3258
+ nodeId: this.config?.nodeId ?? null,
3259
+ publicKey: this.config?.publicKey ?? null,
3260
+ signingPublicKey: this.config?.signingPublicKey ?? null,
3261
+ listenPort: this.config?.listenPort ?? null,
3262
+ externalEndpoint: this.config?.externalEndpoint ?? null,
3263
+ activePeers: peers.filter((p) => p.membershipStatus === "active" && p.routeOwnership !== "superseded").length,
3264
+ totalPeers: peers.length,
3265
+ activeTunnels: peers.filter((p) => p.sessionState !== "none").length,
3266
+ connectedPeers: peers.filter((p) => p.sessionState === "connected").length,
3267
+ handshakingPeers: peers.filter((p) => p.sessionState === "handshaking").length,
3268
+ queueOnlyPeers: peers.filter((p) => p.deliveryReadiness === "can_queue_only").length,
3269
+ supersededPeers: peers.filter((p) => p.routeOwnership === "superseded").length,
3270
+ };
3271
+ }
3272
+ // =========================================================================
3273
+ // WG Tunnel Control Protocol
3274
+ //
3275
+ // After pairing, ALL peer-to-peer control operations flow through the
3276
+ // encrypted WG tunnel — no HTTPS needed. Two message types:
3277
+ //
3278
+ // 1. { controlRequest: { type, ... } } — request from peer
3279
+ // 2. { controlResponse: { type, ... } } — response to a request
3280
+ //
3281
+ // Supported control request types:
3282
+ // - "peerList" → returns list of active peers
3283
+ // - "heartbeat" → updates last-seen timestamp
3284
+ // =========================================================================
3285
+ /** Handle an incoming control request from a peer through the WG tunnel. */
3286
+ async handleControlRequest(request, _peerPublicKey, peerInfo, tunnel) {
3287
+ if (!request || typeof request.type !== "string")
3288
+ return;
3289
+ switch (request.type) {
3290
+ case "peerList": {
3291
+ // Respond with the peer list through the same tunnel
3292
+ const peers = this.listPeers().map((p) => ({
3293
+ nodeId: p.nodeId,
3294
+ displayNameSnapshot: p.name,
3295
+ name: p.name,
3296
+ publicKey: p.publicKey,
3297
+ status: p.status,
3298
+ endpointHost: p.endpointHost,
3299
+ endpointPort: p.endpointPort,
3300
+ lastHandshake: p.lastHandshake,
3301
+ createdAt: p.createdAt,
3302
+ }));
3303
+ tunnel.sendPlaintext(Buffer.from(JSON.stringify({ controlResponse: { type: "peerList", peers } })));
3304
+ break;
3305
+ }
3306
+ case "heartbeat": {
3307
+ const requesterNodeId = this.resolvePeerPrincipalId(peerInfo);
3308
+ if (requesterNodeId) {
3309
+ this.heartbeat(requesterNodeId);
3310
+ }
3311
+ break;
3312
+ }
3313
+ default:
3314
+ // Unknown control request type — ignore (forward compatibility)
3315
+ break;
3316
+ }
3317
+ }
3318
+ /** Pending peer list responses from control requests we sent, keyed by peer transport identity. */
3319
+ pendingPeerListCallbacks = new Map();
3320
+ /** Handle a control response from a peer (response to our request). */
3321
+ handleControlResponse(response, peerInfo) {
3322
+ if (!response || typeof response.type !== "string")
3323
+ return;
3324
+ if (response.type === "peerList" && Array.isArray(response.peers)) {
3325
+ const callback = this.pendingPeerListCallbacks.get(peerInfo.publicKey);
3326
+ if (callback) {
3327
+ this.pendingPeerListCallbacks.delete(peerInfo.publicKey);
3328
+ callback(response.peers);
3329
+ }
3330
+ }
3331
+ }
3332
+ resolvePeerPrincipalId(peer) {
3333
+ if (peer.nodeId) {
3334
+ return peer.nodeId;
3335
+ }
3336
+ if (this.config?.publicKey === peer.publicKey) {
3337
+ return this.localNodeId ?? null;
3338
+ }
3339
+ return null;
3340
+ }
3341
+ getDurablePeerSigningKey(peerPublicKey) {
3342
+ const signingPublicKey = this.peerRegistry?.getWithPsk(peerPublicKey)?.signingPublicKey ?? null;
3343
+ if (!signingPublicKey) {
3344
+ return null;
3345
+ }
3346
+ return trySigningKeyFingerprintFromPublicKey(signingPublicKey) ? signingPublicKey : null;
3347
+ }
3348
+ getPeerSigningFingerprintByPublicKey(peerPublicKey) {
3349
+ const signingPublicKey = this.getDurablePeerSigningKey(peerPublicKey);
3350
+ if (!signingPublicKey) {
3351
+ return null;
3352
+ }
3353
+ return trySigningKeyFingerprintFromPublicKey(signingPublicKey);
3354
+ }
3355
+ /**
3356
+ * Request the peer list from a remote peer through the WG tunnel.
3357
+ * Returns a promise that resolves with the peer list or rejects on timeout.
3358
+ */
3359
+ requestPeerListViaTunnel(peerPublicKey, timeoutMs = 10_000) {
3360
+ const tunnel = this.tunnels.get(peerPublicKey);
3361
+ if (!tunnel)
3362
+ return Promise.reject(new Error("No tunnel to peer"));
3363
+ const peerInfo = this.peerRegistry?.getWithPsk(peerPublicKey);
3364
+ if (!peerInfo)
3365
+ return Promise.reject(new Error("Peer not found in registry"));
3366
+ return new Promise((resolve, reject) => {
3367
+ const timer = setTimeout(() => {
3368
+ this.pendingPeerListCallbacks.delete(peerPublicKey);
3369
+ reject(new Error("Peer list request timed out"));
3370
+ }, timeoutMs);
3371
+ this.pendingPeerListCallbacks.set(peerPublicKey, (peers) => {
3372
+ clearTimeout(timer);
3373
+ resolve(peers);
3374
+ });
3375
+ tunnel.sendPlaintext(Buffer.from(JSON.stringify({ controlRequest: { type: "peerList" } })));
3376
+ });
3377
+ }
3378
+ /**
3379
+ * Send a heartbeat through the WG tunnel to a peer.
3380
+ * This is usually unnecessary — WG handshake timestamps track liveness —
3381
+ * but can be used for application-layer keepalive.
3382
+ */
3383
+ sendHeartbeatViaTunnel(peerPublicKey) {
3384
+ const tunnel = this.tunnels.get(peerPublicKey);
3385
+ if (!tunnel)
3386
+ return;
3387
+ tunnel.sendPlaintext(Buffer.from(JSON.stringify({ controlRequest: { type: "heartbeat" } })));
3388
+ }
3389
+ }
3390
+ exports.NetworkManager = NetworkManager;
3391
+ //# sourceMappingURL=network.js.map