@mmmbuto/nexuscrew 0.8.19 → 0.8.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -3
- package/frontend/dist/assets/index-CQnOyaXz.js +93 -0
- package/frontend/dist/assets/index-DQbVJLji.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/init.js +9 -1
- package/lib/decks/routes.js +2 -2
- package/lib/decks/store.js +34 -5
- package/lib/fleet/builtin.js +18 -4
- package/lib/fleet/catalogs/openrouter-kimi-k3.json +44 -0
- package/lib/fleet/managed.js +109 -5
- package/lib/fleet/openrouter-auth-helper.js +21 -0
- package/lib/nodes/commands.js +35 -2
- package/lib/nodes/peering.js +45 -3
- package/lib/nodes/store.js +51 -10
- package/lib/nodes/tunnel-supervisor.js +47 -4
- package/lib/nodes/tunnel.js +38 -1
- package/lib/proxy/federation.js +123 -18
- package/lib/server.js +11 -0
- package/lib/settings/pairing-coordinator.js +3 -3
- package/lib/settings/public-peering-routes.js +39 -11
- package/lib/settings/routes.js +31 -29
- package/lib/tmux/list.js +11 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-DOHx885g.js +0 -91
- package/frontend/dist/assets/index-kxpt4ezv.css +0 -32
package/lib/nodes/peering.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('node:crypto');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
|
+
const net = require('node:net');
|
|
5
6
|
const path = require('node:path');
|
|
6
7
|
const store = require('./store.js');
|
|
7
8
|
|
|
@@ -132,9 +133,14 @@ function consumeInvite({ invitesPath, invite, now = Date.now() }) {
|
|
|
132
133
|
return true;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
function
|
|
136
|
+
function hasInvite({ invitesPath, invite, now = Date.now() }) {
|
|
137
|
+
const hash = crypto.createHash('sha256').update(String(invite || '')).digest('hex');
|
|
138
|
+
return readInvites(invitesPath, now).some((row) => safeEqual(row.hash, hash));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function allocateReversePort(nodes, reservations = []) {
|
|
136
142
|
const used = new Set();
|
|
137
|
-
for (const n of nodes || []) {
|
|
143
|
+
for (const n of [...(nodes || []), ...(reservations || [])]) {
|
|
138
144
|
if (store.isPort(n.localPort)) used.add(n.localPort);
|
|
139
145
|
if (store.isPort(n.reversePort)) used.add(n.reversePort);
|
|
140
146
|
}
|
|
@@ -144,6 +150,41 @@ function allocateReversePort(nodes) {
|
|
|
144
150
|
return p;
|
|
145
151
|
}
|
|
146
152
|
|
|
153
|
+
function canBindReversePort(port, createServerImpl = net.createServer) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const server = createServerImpl();
|
|
156
|
+
let settled = false;
|
|
157
|
+
const finish = (value, error = null) => {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
settled = true;
|
|
160
|
+
server.removeAllListeners?.();
|
|
161
|
+
if (error) reject(error); else resolve(value);
|
|
162
|
+
};
|
|
163
|
+
server.once('error', (error) => {
|
|
164
|
+
if (error && (error.code === 'EADDRINUSE' || error.code === 'EACCES')) finish(false);
|
|
165
|
+
else finish(false, error);
|
|
166
|
+
});
|
|
167
|
+
server.once('listening', () => {
|
|
168
|
+
if (typeof server.unref === 'function') server.unref();
|
|
169
|
+
try { server.close(() => finish(true)); } catch (error) { finish(false, error); }
|
|
170
|
+
});
|
|
171
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The reverse listener will be owned by sshd, but allocation happens on the
|
|
176
|
+
// hub. Probe the real loopback bind before issuing a candidate so a stale or
|
|
177
|
+
// unrelated listener absent from nodes.json cannot be reallocated blindly.
|
|
178
|
+
async function allocateAvailableReversePort(nodes, reservations = [], opts = {}) {
|
|
179
|
+
const rejected = [];
|
|
180
|
+
while (rejected.length < (65535 - REVERSE_PORT_BASE)) {
|
|
181
|
+
const candidate = allocateReversePort(nodes, [...reservations, ...rejected]);
|
|
182
|
+
if (await canBindReversePort(candidate, opts.createServerImpl || net.createServer)) return candidate;
|
|
183
|
+
rejected.push({ localPort: candidate });
|
|
184
|
+
}
|
|
185
|
+
throw new Error('no reverse port available');
|
|
186
|
+
}
|
|
187
|
+
|
|
147
188
|
function createPending({ pendingPath, data, now = Date.now() }) {
|
|
148
189
|
const rows = readInvites(pendingPath, now);
|
|
149
190
|
const credential = crypto.randomBytes(32).toString('base64url');
|
|
@@ -269,6 +310,7 @@ async function probeTransportReady({
|
|
|
269
310
|
module.exports = {
|
|
270
311
|
INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
|
|
271
312
|
readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
|
|
272
|
-
createInvite, consumeInvite,
|
|
313
|
+
createInvite, consumeInvite, hasInvite, allocateReversePort, canBindReversePort,
|
|
314
|
+
allocateAvailableReversePort, createPending, consumePending,
|
|
273
315
|
capabilityIdentity, capabilityProbeMaterial, probeTransportReady,
|
|
274
316
|
};
|
package/lib/nodes/store.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// - nessun token in output redatto (redactStore/redactNode)
|
|
10
10
|
//
|
|
11
11
|
// Modulo PURO al parse: parseStore/parseNode non toccano il filesystem.
|
|
12
|
-
// Tutto l'I/O vive in loadStore/atomicWriteStore/
|
|
12
|
+
// Tutto l'I/O vive in loadStore/loadStoreStrict/initStore/atomicWriteStore/
|
|
13
|
+
// migrateLegacyNodes.
|
|
13
14
|
// Principio fail-closed: qualunque dato malformato -> null (parse) o throw
|
|
14
15
|
// esplicito (mutazioni/write), mai un default silenzioso.
|
|
15
16
|
const fs = require('node:fs');
|
|
@@ -227,12 +228,15 @@ function parseStore(raw) {
|
|
|
227
228
|
|
|
228
229
|
const names = new Set();
|
|
229
230
|
const ids = new Set();
|
|
231
|
+
const localPorts = new Set();
|
|
230
232
|
const nodes = [];
|
|
231
233
|
for (const raw2 of d.nodes) {
|
|
232
234
|
const node = parseNode(raw2, d.schemaVersion);
|
|
233
235
|
if (!node) return null;
|
|
234
236
|
if (names.has(node.name)) return null; // name univoco
|
|
235
237
|
names.add(node.name);
|
|
238
|
+
if (localPorts.has(node.localPort)) return null; // ogni listener locale ha un solo owner
|
|
239
|
+
localPorts.add(node.localPort);
|
|
236
240
|
if (node.nodeId) {
|
|
237
241
|
if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
|
|
238
242
|
if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
|
|
@@ -306,22 +310,56 @@ function emptyStore(nodeId) {
|
|
|
306
310
|
return { schemaVersion: SCHEMA_VERSION, nodeId: nodeId || newNodeId(), nodes: [] };
|
|
307
311
|
}
|
|
308
312
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
function storeUnavailable(code, message) {
|
|
314
|
+
const e = new Error(message);
|
|
315
|
+
e.status = 503;
|
|
316
|
+
e.code = code;
|
|
317
|
+
return e;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Runtime strict: nodes.json e' identita' + credential store. Se sparisce non
|
|
321
|
+
// deve MAI essere rigenerato implicitamente da una route/command: un nuovo
|
|
322
|
+
// nodeId farebbe apparire l'installazione come un altro dispositivo e
|
|
323
|
+
// maschererebbe una perdita di stato. Solo `nexuscrew init` usa initStore().
|
|
324
|
+
function loadStoreStrict(p) {
|
|
313
325
|
let st;
|
|
314
326
|
try { st = fs.lstatSync(p); }
|
|
315
327
|
catch (e) {
|
|
316
|
-
if (e.code === 'ENOENT')
|
|
328
|
+
if (e.code === 'ENOENT') {
|
|
329
|
+
throw storeUnavailable('NODES_STORE_MISSING',
|
|
330
|
+
`nodes.json assente (${p}): esegui \`nexuscrew init\` per inizializzare esplicitamente lo store`);
|
|
331
|
+
}
|
|
317
332
|
throw e;
|
|
318
333
|
}
|
|
319
|
-
if (st.isSymbolicLink())
|
|
334
|
+
if (st.isSymbolicLink()) {
|
|
335
|
+
throw storeUnavailable('NODES_STORE_INVALID', 'nodes.json e\' un symlink (rifiutato)');
|
|
336
|
+
}
|
|
337
|
+
if (!st.isFile()) {
|
|
338
|
+
throw storeUnavailable('NODES_STORE_INVALID', 'nodes.json non e\' un file regolare');
|
|
339
|
+
}
|
|
320
340
|
const parsed = loadStore(p);
|
|
321
|
-
if (!parsed)
|
|
341
|
+
if (!parsed) {
|
|
342
|
+
throw storeUnavailable('NODES_STORE_INVALID',
|
|
343
|
+
'nodes.json presente ma invalido (schema strict): ripristina un backup valido; non viene sovrascritto');
|
|
344
|
+
}
|
|
322
345
|
return parsed;
|
|
323
346
|
}
|
|
324
347
|
|
|
348
|
+
// Init esplicito e idempotente. E' l'unico percorso autorizzato a creare uno
|
|
349
|
+
// store mancante; un file presente ma invalido resta fail-closed.
|
|
350
|
+
function initStore(p) {
|
|
351
|
+
try { fs.lstatSync(p); }
|
|
352
|
+
catch (e) {
|
|
353
|
+
if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
|
|
354
|
+
throw e;
|
|
355
|
+
}
|
|
356
|
+
return loadStoreStrict(p);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Compatibilita' API interna: il vecchio nome non inizializza piu' a runtime.
|
|
360
|
+
// Tenerlo strict impedisce che un call-site dimenticato reintroduca F1.
|
|
361
|
+
function loadOrInitStore(p) { return loadStoreStrict(p); }
|
|
362
|
+
|
|
325
363
|
// --- Mutazioni pure (ritornano un nuovo store; il caller lo scrive) ---------
|
|
326
364
|
|
|
327
365
|
function getNode(store, name) {
|
|
@@ -334,6 +372,9 @@ function addNode(store, entry) {
|
|
|
334
372
|
if (store.nodes.some((n) => n.name === node.name)) {
|
|
335
373
|
throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
|
|
336
374
|
}
|
|
375
|
+
if (store.nodes.some((n) => n.localPort === node.localPort)) {
|
|
376
|
+
throw new Error(`nodo duplicato: localPort ${node.localPort} gia' assegnata`);
|
|
377
|
+
}
|
|
337
378
|
if (node.nodeId) {
|
|
338
379
|
if (node.nodeId === store.nodeId) throw new Error('self-reference: il nodeId coincide con questa installazione');
|
|
339
380
|
if (store.nodes.some((n) => n.nodeId === node.nodeId)) {
|
|
@@ -425,7 +466,7 @@ function migrateLegacyNodes(configPath, nodesPath) {
|
|
|
425
466
|
if (!cfg || typeof cfg !== 'object' || !Array.isArray(cfg.nodes) || cfg.nodes.length === 0) {
|
|
426
467
|
return { migrated: false, count: 0, reason: 'nessun campo nodes legacy in config.json' };
|
|
427
468
|
}
|
|
428
|
-
const store =
|
|
469
|
+
const store = initStore(nodesPath);
|
|
429
470
|
if (store.nodes.length > 0) {
|
|
430
471
|
return { migrated: false, count: 0, reason: 'nodes.json gia\' popolato (no overwrite)' };
|
|
431
472
|
}
|
|
@@ -498,7 +539,7 @@ module.exports = {
|
|
|
498
539
|
// parse/validate
|
|
499
540
|
parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
500
541
|
// I/O
|
|
501
|
-
defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
542
|
+
defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
502
543
|
// mutazioni
|
|
503
544
|
getNode, addNode, removeNode, setNodeToken, updateNode,
|
|
504
545
|
// redazione
|
|
@@ -6,7 +6,7 @@ const fs = require('node:fs');
|
|
|
6
6
|
const net = require('node:net');
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const { spawn } = require('node:child_process');
|
|
9
|
-
const { backoffDelay } = require('./tunnel.js');
|
|
9
|
+
const { backoffDelay, classifySshFailure } = require('./tunnel.js');
|
|
10
10
|
|
|
11
11
|
const sshBin = process.argv[2];
|
|
12
12
|
const sshArgs = process.argv.slice(3);
|
|
@@ -18,6 +18,9 @@ const stableMs = Number.isFinite(stableMsRaw) && stableMsRaw >= 100 ? Math.min(s
|
|
|
18
18
|
const ownershipGraceRaw = Number(process.env.NEXUSCREW_TUNNEL_OWNERSHIP_GRACE_MS || 2000);
|
|
19
19
|
const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw >= 100
|
|
20
20
|
? Math.min(ownershipGraceRaw, 10000) : 2000;
|
|
21
|
+
const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
|
|
22
|
+
const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
|
|
23
|
+
? Math.min(reverseFailureMaxRaw, 32) : 8;
|
|
21
24
|
if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
|
|
22
25
|
|
|
23
26
|
let child = null;
|
|
@@ -29,6 +32,7 @@ let forwardProbeTimer = null;
|
|
|
29
32
|
let forwardSocket = null;
|
|
30
33
|
let ownershipWaitTimer = null;
|
|
31
34
|
let ownershipTimer = null;
|
|
35
|
+
let reverseFailures = 0;
|
|
32
36
|
|
|
33
37
|
function localForwardPort(args) {
|
|
34
38
|
for (let i = 0; i < args.length - 1; i += 1) {
|
|
@@ -42,6 +46,18 @@ function localForwardPort(args) {
|
|
|
42
46
|
|
|
43
47
|
const forwardPort = localForwardPort(sshArgs);
|
|
44
48
|
|
|
49
|
+
function reverseForwardPort(args) {
|
|
50
|
+
for (let i = 0; i < args.length - 1; i += 1) {
|
|
51
|
+
if (args[i] !== '-R') continue;
|
|
52
|
+
const match = String(args[i + 1] || '').match(/^127\.0\.0\.1:(\d+):127\.0\.0\.1:\d+$/);
|
|
53
|
+
const port = match ? Number(match[1]) : 0;
|
|
54
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reversePort = reverseForwardPort(sshArgs);
|
|
60
|
+
|
|
45
61
|
function logEvent(message) {
|
|
46
62
|
try { process.stderr.write(`[nexuscrew] ${String(message).replace(/[\r\n]+/g, ' ')}\n`); } catch (_) {}
|
|
47
63
|
}
|
|
@@ -121,17 +137,34 @@ function scheduleRetry(detail) {
|
|
|
121
137
|
retryTimer = setTimeout(run, delayMs);
|
|
122
138
|
}
|
|
123
139
|
|
|
140
|
+
function terminalFailure(diagnosis) {
|
|
141
|
+
const safe = diagnosis || { code: 'reverse-forward-failed', detail: 'canale inverso non disponibile' };
|
|
142
|
+
logEvent(`ssh terminal failure code=${safe.code} attempts=${reverseFailures}`);
|
|
143
|
+
writeState('failed', {
|
|
144
|
+
code: safe.code, detail: safe.detail,
|
|
145
|
+
...(safe.hint ? { hint: safe.hint } : {}), terminal: true,
|
|
146
|
+
});
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
function run() {
|
|
125
151
|
if (stopping) return finish();
|
|
126
152
|
if (!writeState('starting')) return stop();
|
|
127
153
|
logEvent(`ssh attempt=${attempt + 1} starting`);
|
|
154
|
+
let stderrTail = '';
|
|
128
155
|
try {
|
|
129
|
-
child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
|
|
156
|
+
child = spawn(sshBin, sshArgs, { stdio: ['ignore', 'inherit', 'pipe'] });
|
|
130
157
|
} catch (e) {
|
|
131
158
|
child = null;
|
|
132
159
|
return scheduleRetry(String(e && e.message || e));
|
|
133
160
|
}
|
|
134
161
|
|
|
162
|
+
child.stderr?.on('data', (chunk) => {
|
|
163
|
+
const text = String(chunk || '');
|
|
164
|
+
stderrTail = `${stderrTail}${text}`.slice(-8192);
|
|
165
|
+
try { process.stderr.write(chunk); } catch (_) {}
|
|
166
|
+
});
|
|
167
|
+
|
|
135
168
|
let failureHandled = false;
|
|
136
169
|
const handleFailure = (detail) => {
|
|
137
170
|
if (failureHandled) return;
|
|
@@ -139,7 +172,15 @@ function run() {
|
|
|
139
172
|
clearTimeout(upTimer);
|
|
140
173
|
clearForwardProbe();
|
|
141
174
|
child = null;
|
|
142
|
-
|
|
175
|
+
const diagnosis = reversePort ? classifySshFailure(stderrTail, reversePort) : null;
|
|
176
|
+
const reverseFailure = diagnosis && ['reverse-forward-bind', 'reverse-forward-failed'].includes(diagnosis.code);
|
|
177
|
+
if (reverseFailure) {
|
|
178
|
+
reverseFailures += 1;
|
|
179
|
+
if (reverseFailures >= reverseFailureMax) return terminalFailure(diagnosis);
|
|
180
|
+
} else {
|
|
181
|
+
reverseFailures = 0;
|
|
182
|
+
}
|
|
183
|
+
scheduleRetry((diagnosis && diagnosis.detail) || detail);
|
|
143
184
|
};
|
|
144
185
|
child.once('spawn', () => {
|
|
145
186
|
logEvent(`ssh attempt=${attempt + 1} spawned`);
|
|
@@ -156,7 +197,9 @@ function run() {
|
|
|
156
197
|
logEvent(`ssh child error code=${(e && e.code) || 'unknown'}`);
|
|
157
198
|
handleFailure(String(e && e.message || e));
|
|
158
199
|
});
|
|
159
|
-
|
|
200
|
+
// `close` fires after stderr has drained, so classification sees the complete
|
|
201
|
+
// OpenSSH diagnostic instead of racing the child `exit` event.
|
|
202
|
+
child.once('close', (code, signal) => {
|
|
160
203
|
if (stopping) return finish();
|
|
161
204
|
logEvent(`ssh exited code=${code === null ? 'null' : code} signal=${signal || 'none'}`);
|
|
162
205
|
handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -209,7 +209,18 @@ function classifySshFailure(text, remotePort) {
|
|
|
209
209
|
return { code: 'ssh-config', detail: 'configurazione SSH non utilizzabile', hint: 'verifica permessi e opzioni del file ~/.ssh/config' };
|
|
210
210
|
}
|
|
211
211
|
if (/remote port forwarding failed|Could not request remote forwarding/i.test(s)) {
|
|
212
|
-
|
|
212
|
+
if (/Address already in use|cannot listen|bind .*failed/i.test(s)) {
|
|
213
|
+
return {
|
|
214
|
+
code: 'reverse-forward-bind',
|
|
215
|
+
detail: 'il canale inverso non può aprire la porta sul nodo hub perché è già occupata',
|
|
216
|
+
hint: 'verifica un precedente listener SSH sulla stessa porta e l’eventuale doppio supervisor',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
code: 'reverse-forward-failed',
|
|
221
|
+
detail: 'il canale inverso non è stato aperto dal nodo hub',
|
|
222
|
+
hint: 'la porta può essere già occupata oppure il server può vietare il forwarding; verifica listener e policy SSH sul nodo hub',
|
|
223
|
+
};
|
|
213
224
|
}
|
|
214
225
|
if (/Could not request local forwarding|Address already in use/i.test(s)) {
|
|
215
226
|
return { code: 'local-forward-bind', detail: 'la porta locale scelta per il tunnel non è disponibile', hint: 'ferma il tunnel precedente e riprova' };
|
|
@@ -245,12 +256,27 @@ function tunnelStatePath(home, name) {
|
|
|
245
256
|
function readTunnelState(home, name) {
|
|
246
257
|
const p = tunnelPidPath(home, name);
|
|
247
258
|
const meta = pidf.readPidfile(p);
|
|
259
|
+
const terminalState = () => {
|
|
260
|
+
try {
|
|
261
|
+
const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
|
|
262
|
+
const owned = meta && state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
|
|
263
|
+
if (!owned || state.status !== 'failed' || state.terminal !== true) return null;
|
|
264
|
+
return {
|
|
265
|
+
status: 'down', phase: 'failed', reason: 'failed', pid: meta.pid, since: meta.startTs || null,
|
|
266
|
+
...(typeof state.code === 'string' ? { code: state.code } : {}),
|
|
267
|
+
...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
|
|
268
|
+
...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
|
|
269
|
+
...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
|
|
270
|
+
};
|
|
271
|
+
} catch (_) { return null; }
|
|
272
|
+
};
|
|
248
273
|
if (meta && pidf.isAlive(meta)) {
|
|
249
274
|
try {
|
|
250
275
|
const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
|
|
251
276
|
const transport = typeof state.transport === 'string' ? state.transport : undefined;
|
|
252
277
|
const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
|
|
253
278
|
if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
|
|
279
|
+
if (state.status === 'failed' && state.terminal === true) return terminalState();
|
|
254
280
|
if (state.status === 'transport-ready' || state.status === 'up') {
|
|
255
281
|
return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
|
|
256
282
|
...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
|
|
@@ -267,6 +293,10 @@ function readTunnelState(home, name) {
|
|
|
267
293
|
return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
|
|
268
294
|
}
|
|
269
295
|
}
|
|
296
|
+
if (meta) {
|
|
297
|
+
const terminal = terminalState();
|
|
298
|
+
if (terminal) return terminal;
|
|
299
|
+
}
|
|
270
300
|
return { status: 'down' };
|
|
271
301
|
}
|
|
272
302
|
|
|
@@ -287,6 +317,13 @@ function diagnoseTunnel(home, node, state = null) {
|
|
|
287
317
|
detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
|
|
288
318
|
hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
|
|
289
319
|
}
|
|
320
|
+
if (current.reason === 'failed' || current.phase === 'failed') {
|
|
321
|
+
return {
|
|
322
|
+
stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
|
|
323
|
+
detail: current.detail || 'connessione SSH fermata dopo errori ripetuti',
|
|
324
|
+
hint: current.hint || 'correggi la causa e riavvia la connessione dalla PWA',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
290
327
|
return { stage: 'ssh', code: 'tunnel-stopped', status: 'down', phase: current.phase || null,
|
|
291
328
|
detail: 'connessione SSH ferma', hint: 'avvia la connessione dalla PWA, Impostazioni > Nodi' };
|
|
292
329
|
}
|
package/lib/proxy/federation.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
|
|
14
14
|
const MAX_HOPS = 4;
|
|
15
15
|
const ROUTE_DELIMITER = '_';
|
|
16
|
+
const TOPOLOGY_PEER_TIMEOUT_MS = 1500;
|
|
16
17
|
|
|
17
18
|
function peerFromToken(nodesPath, token) {
|
|
18
19
|
const st = store.loadStore(nodesPath);
|
|
@@ -158,10 +159,16 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
|
|
|
158
159
|
let timer;
|
|
159
160
|
try {
|
|
160
161
|
const ctrl = new AbortController();
|
|
161
|
-
|
|
162
|
-
r = await fetchImpl(`http://127.0.0.1:${port}/federation/health`, {
|
|
162
|
+
const request = fetchImpl(`http://127.0.0.1:${port}/federation/health`, {
|
|
163
163
|
headers: { authorization: `Bearer ${token}` }, signal: ctrl.signal,
|
|
164
164
|
});
|
|
165
|
+
const timeout = new Promise((_, reject) => {
|
|
166
|
+
timer = setTimeout(() => {
|
|
167
|
+
ctrl.abort();
|
|
168
|
+
const e = new Error(`health timeout (${timeoutMs}ms)`); e.name = 'AbortError'; reject(e);
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
});
|
|
171
|
+
r = await Promise.race([request, timeout]);
|
|
165
172
|
} catch (e) {
|
|
166
173
|
out.transport = 'down';
|
|
167
174
|
out.status = 'down';
|
|
@@ -228,6 +235,71 @@ async function waitForHealthyPeer(opts = {}) {
|
|
|
228
235
|
return last || { status: 'down', detail: 'peer non raggiungibile' };
|
|
229
236
|
}
|
|
230
237
|
|
|
238
|
+
// Aggiorna lo stato Share sul hub attraverso il canale -L autenticato. Non
|
|
239
|
+
// legge mai il body remoto (potrebbe contenere diagnostica non sicura) e non
|
|
240
|
+
// include credenziali negli errori. Usato sia dal toggle interattivo sia dalla
|
|
241
|
+
// riconciliazione al boot.
|
|
242
|
+
async function notifyHubShare({ node, shared, fetchImpl = fetch, timeoutMs = 5000 }) {
|
|
243
|
+
if (!node || !store.isPort(node.localPort) || !store.validToken(node.token)
|
|
244
|
+
|| typeof shared !== 'boolean') throw new Error('parametri riconciliazione Share non validi');
|
|
245
|
+
const ctrl = new AbortController();
|
|
246
|
+
const budget = Number.isInteger(timeoutMs) ? Math.max(100, Math.min(timeoutMs, 30000)) : 5000;
|
|
247
|
+
let timer;
|
|
248
|
+
try {
|
|
249
|
+
const request = fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
|
|
250
|
+
method: 'POST', signal: ctrl.signal,
|
|
251
|
+
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
|
252
|
+
body: JSON.stringify({ shared }),
|
|
253
|
+
});
|
|
254
|
+
const timeout = new Promise((_, reject) => {
|
|
255
|
+
timer = setTimeout(() => {
|
|
256
|
+
ctrl.abort();
|
|
257
|
+
const e = new Error(`hub Share timeout (${budget}ms)`); e.code = 'ETIMEDOUT'; reject(e);
|
|
258
|
+
}, budget);
|
|
259
|
+
});
|
|
260
|
+
const response = await Promise.race([request, timeout]);
|
|
261
|
+
if (!response || !response.ok) throw new Error(`hub Share HTTP ${response && response.status || 'unknown'}`);
|
|
262
|
+
return { shared };
|
|
263
|
+
} finally { clearTimeout(timer); }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Il file locale contiene lo stato desiderato. Dopo un crash in qualunque
|
|
267
|
+
// punto del toggle, il boot ristabilisce il tunnel coerente e ripete l'update
|
|
268
|
+
// del hub: ON torna pubblicato, OFF revoca record stale. Tutto e' bounded.
|
|
269
|
+
async function reconcilePeerShare(opts = {}) {
|
|
270
|
+
const node = opts.node;
|
|
271
|
+
const shared = opts.shared === true;
|
|
272
|
+
if (!node || !store.validToken(node.token) || !store.NODE_ID_RE.test(String(node.nodeId || ''))) {
|
|
273
|
+
throw new Error('peer non associato: riconciliazione Share impossibile');
|
|
274
|
+
}
|
|
275
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
276
|
+
const delay = typeof opts.delay === 'function'
|
|
277
|
+
? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
278
|
+
const health = await waitForHealthyPeer({
|
|
279
|
+
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId,
|
|
280
|
+
fetchImpl,
|
|
281
|
+
attempts: Number.isInteger(opts.healthAttempts) ? opts.healthAttempts : 6,
|
|
282
|
+
delayMs: Number.isInteger(opts.delayMs) ? opts.delayMs : 200,
|
|
283
|
+
delay,
|
|
284
|
+
});
|
|
285
|
+
if (!health || health.status !== 'healthy') {
|
|
286
|
+
throw new Error((health && health.detail) || 'hub non raggiungibile per riconciliare Share');
|
|
287
|
+
}
|
|
288
|
+
const attempts = Number.isInteger(opts.notifyAttempts)
|
|
289
|
+
? Math.max(1, Math.min(opts.notifyAttempts, 6)) : (shared ? 3 : 1);
|
|
290
|
+
let lastError = null;
|
|
291
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
292
|
+
try {
|
|
293
|
+
await notifyHubShare({ node, shared, fetchImpl, timeoutMs: opts.timeoutMs });
|
|
294
|
+
return { shared, health };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
lastError = e;
|
|
297
|
+
if (attempt + 1 < attempts) await delay(Number.isInteger(opts.delayMs) ? opts.delayMs : 200);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
throw lastError || new Error('riconciliazione Share fallita');
|
|
301
|
+
}
|
|
302
|
+
|
|
231
303
|
function controlledVisited(req, ingress, instanceId) {
|
|
232
304
|
const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
|
|
233
305
|
const seen = raw ? raw.split(',').filter(Boolean) : [];
|
|
@@ -241,13 +313,41 @@ function controlledVisited(req, ingress, instanceId) {
|
|
|
241
313
|
return [...seen, instanceId];
|
|
242
314
|
}
|
|
243
315
|
|
|
244
|
-
async function
|
|
316
|
+
async function fetchPeerTopology({ node, ttl, seen, fetchImpl, timeoutMs }) {
|
|
317
|
+
const ctrl = new AbortController();
|
|
318
|
+
const budget = Number.isFinite(timeoutMs)
|
|
319
|
+
? Math.max(1, Math.min(30000, Math.floor(timeoutMs))) : TOPOLOGY_PEER_TIMEOUT_MS;
|
|
320
|
+
let timer;
|
|
321
|
+
const timeout = new Promise((_, reject) => {
|
|
322
|
+
timer = setTimeout(() => {
|
|
323
|
+
ctrl.abort();
|
|
324
|
+
const error = new Error(`topology peer timeout (${budget}ms)`);
|
|
325
|
+
error.code = 'ETIMEDOUT';
|
|
326
|
+
reject(error);
|
|
327
|
+
}, budget);
|
|
328
|
+
});
|
|
329
|
+
const request = (async () => {
|
|
330
|
+
const u = `http://127.0.0.1:${node.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
|
|
331
|
+
const response = await fetchImpl(u, {
|
|
332
|
+
headers: { authorization: `Bearer ${node.token}` }, signal: ctrl.signal,
|
|
333
|
+
});
|
|
334
|
+
return { response, body: await response.json() };
|
|
335
|
+
})();
|
|
336
|
+
try { return await Promise.race([request, timeout]); }
|
|
337
|
+
finally { clearTimeout(timer); }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function collectTopologyDetailed({
|
|
341
|
+
nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch,
|
|
342
|
+
timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
|
|
343
|
+
}) {
|
|
245
344
|
const st = store.loadStore(nodesPath);
|
|
246
345
|
if (!st) return { instanceId: null, nodes: [], authoritative: [] };
|
|
247
346
|
const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
|
|
248
347
|
seen.add(st.nodeId);
|
|
249
348
|
const out = [];
|
|
250
349
|
const authoritative = [];
|
|
350
|
+
const probes = [];
|
|
251
351
|
for (const n of st.nodes) {
|
|
252
352
|
// The local installation always keeps its outbound hub visible. Inbound
|
|
253
353
|
// clients become part of Hydra only after their explicit Share toggle.
|
|
@@ -256,13 +356,15 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
|
|
|
256
356
|
|| (ingress && !canTransit(ingress, n))) continue;
|
|
257
357
|
out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
|
|
258
358
|
if (ttl <= 1 || !n.token) continue;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
359
|
+
probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs }) });
|
|
360
|
+
}
|
|
361
|
+
const results = await Promise.all(probes.map(async ({ n, pending }) => {
|
|
362
|
+
try { return { n, ...(await pending) }; } catch (_) { return { n, response: null, body: null }; }
|
|
363
|
+
}));
|
|
364
|
+
for (const { n, response, body } of results) {
|
|
365
|
+
if (!response || !response.ok || body.instanceId !== n.nodeId || !Array.isArray(body.nodes)) continue;
|
|
366
|
+
authoritative.push(n.name);
|
|
367
|
+
for (const child of body.nodes) {
|
|
266
368
|
if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
|
|
267
369
|
|| !store.NODE_NAME_RE.test(child.name)
|
|
268
370
|
|| !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
|
|
@@ -271,8 +373,7 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
|
|
|
271
373
|
|| child.name !== child.route[child.route.length - 1]
|
|
272
374
|
|| child.route.includes(n.name)) continue;
|
|
273
375
|
out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
|
|
274
|
-
|
|
275
|
-
} catch (_) {}
|
|
376
|
+
}
|
|
276
377
|
}
|
|
277
378
|
const ids = new Set(); const routes = new Set(); const unique = [];
|
|
278
379
|
for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
|
|
@@ -290,8 +391,11 @@ async function collectTopology(opts) {
|
|
|
290
391
|
|
|
291
392
|
// Local roster: live topology plus a credential-free cache of previously seen
|
|
292
393
|
// transitive nodes. Stale entries are never returned by the peer endpoint.
|
|
293
|
-
async function collectLocalTopology({
|
|
294
|
-
|
|
394
|
+
async function collectLocalTopology({
|
|
395
|
+
nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000),
|
|
396
|
+
timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
|
|
397
|
+
}) {
|
|
398
|
+
const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs });
|
|
295
399
|
const st = store.loadStore(nodesPath);
|
|
296
400
|
const directNames = new Set(((st && st.nodes) || [])
|
|
297
401
|
.filter((n) => n.direction !== 'inbound' || n.shared === true)
|
|
@@ -373,7 +477,7 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
373
477
|
});
|
|
374
478
|
}
|
|
375
479
|
}
|
|
376
|
-
let st = store.
|
|
480
|
+
let st = store.loadStoreStrict(nodesPath);
|
|
377
481
|
const current = store.getNode(st, req.peer.name);
|
|
378
482
|
if (!current) return res.status(404).json({ error: 'peer non trovato' });
|
|
379
483
|
st = store.updateNode(st, current.name, {
|
|
@@ -384,7 +488,7 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
384
488
|
store.atomicWriteStore(nodesPath, st);
|
|
385
489
|
return res.json({ shared: body.shared });
|
|
386
490
|
} catch (e) {
|
|
387
|
-
return res.status(500).json({ error: String(e && e.message || e) });
|
|
491
|
+
return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
|
|
388
492
|
}
|
|
389
493
|
});
|
|
390
494
|
r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
|
|
@@ -434,7 +538,8 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
434
538
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
435
539
|
|
|
436
540
|
module.exports = {
|
|
437
|
-
MAX_HOPS, ROUTE_DELIMITER,
|
|
541
|
+
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
|
|
542
|
+
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
|
|
438
543
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
439
|
-
probeHealth, waitForHealthyPeer,
|
|
544
|
+
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
|
|
440
545
|
};
|
package/lib/server.js
CHANGED
|
@@ -156,6 +156,17 @@ function createServer(opts = {}) {
|
|
|
156
156
|
});
|
|
157
157
|
if (!tr.started && tr.reason !== 'already running') {
|
|
158
158
|
process.stderr.write(`[nexuscrew] peer ${node.name} autostart failed: ${tr.reason || 'unknown'}\n`);
|
|
159
|
+
} else if (node.token && node.acceptToken && node.nodeId) {
|
|
160
|
+
// `shared` in nodes.json e' lo stato desiderato. Una riconciliazione
|
|
161
|
+
// asincrona chiude la finestra di crash tra write locale e ACK hub
|
|
162
|
+
// senza ritardare il listen del server.
|
|
163
|
+
const reconcileShare = cfg.reconcilePeerShareImpl || federation.reconcilePeerShare;
|
|
164
|
+
Promise.resolve(reconcileShare({
|
|
165
|
+
node, shared: node.shared === true, fetchImpl: healthFetch,
|
|
166
|
+
healthAttempts: 3, notifyAttempts: node.shared === true ? 3 : 1, delayMs: 200,
|
|
167
|
+
})).catch((e) => {
|
|
168
|
+
process.stderr.write(`[nexuscrew] peer ${node.name} Share reconcile pending: ${String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***')}\n`);
|
|
169
|
+
});
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
}
|
|
@@ -117,7 +117,7 @@ function createPairHandler(deps) {
|
|
|
117
117
|
|
|
118
118
|
try {
|
|
119
119
|
// --- conflict: il nome non deve gia' esistere --------------------------
|
|
120
|
-
let st = nodesStore.
|
|
120
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
121
121
|
if (st.nodes.some((n) => n.name === b.name)) {
|
|
122
122
|
return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
|
|
123
123
|
retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
|
|
@@ -253,7 +253,7 @@ function createPairHandler(deps) {
|
|
|
253
253
|
// --- tunnel-final: connessione privata, solo -L -------------------------
|
|
254
254
|
// reversePort resta negoziata per un futuro Share opt-in, ma il builder
|
|
255
255
|
// non emette -R finche' shared non diventa true.
|
|
256
|
-
st = nodesStore.
|
|
256
|
+
st = nodesStore.loadStoreStrict(nodesPath);
|
|
257
257
|
st = nodesStore.updateNode(st, b.name, {
|
|
258
258
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
259
259
|
shared: false,
|
|
@@ -317,7 +317,7 @@ function createPairHandler(deps) {
|
|
|
317
317
|
send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
|
|
318
318
|
} catch (e) {
|
|
319
319
|
await rollback();
|
|
320
|
-
fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
|
|
320
|
+
fail(e.status || 502, 'internal', e.code || 'unexpected', String((e && e.message) || e), {});
|
|
321
321
|
}
|
|
322
322
|
};
|
|
323
323
|
}
|