@aria-cli/wireguard 1.0.38 → 1.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap-authority.d.ts +2 -0
- package/dist/bootstrap-authority.js +47 -0
- package/dist/bootstrap-tls.d.ts +14 -0
- package/dist/bootstrap-tls.js +69 -0
- package/dist/db-owner-fencing.d.ts +10 -0
- package/dist/db-owner-fencing.js +44 -0
- package/dist/derp-relay.d.ts +75 -0
- package/dist/derp-relay.js +311 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +100 -0
- package/dist/nat.d.ts +84 -0
- package/dist/nat.js +397 -0
- package/dist/network-state-store.d.ts +46 -0
- package/dist/network-state-store.js +248 -0
- package/dist/network.d.ts +590 -0
- package/dist/network.js +3391 -0
- package/dist/peer-discovery.d.ts +133 -0
- package/dist/peer-discovery.js +486 -0
- package/dist/resilient-tunnel.d.ts +70 -0
- package/dist/resilient-tunnel.js +389 -0
- package/dist/route-ownership.d.ts +23 -0
- package/dist/route-ownership.js +79 -0
- package/dist/tunnel.d.ts +141 -0
- package/dist/tunnel.js +474 -0
- package/index.js +52 -52
- package/npm/darwin-arm64/package.json +1 -1
- package/npm/darwin-x64/package.json +1 -1
- package/npm/linux-arm64-gnu/package.json +1 -1
- package/npm/linux-x64-gnu/package.json +1 -1
- package/npm/win32-x64-msvc/package.json +1 -1
- package/package.json +13 -20
package/dist/network.js
ADDED
|
@@ -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
|