@mmmbuto/nexuscrew 0.8.5 → 0.8.7
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 +52 -23
- package/frontend/dist/assets/index-D2TrRtWQ.js +90 -0
- package/frontend/dist/assets/index-DrEuy6A6.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/fleet-service.js +8 -3
- package/lib/fleet/builtin.js +139 -22
- package/lib/fleet/definitions.js +20 -0
- package/lib/fleet/index.js +42 -1
- package/lib/fleet/managed.js +15 -2
- package/lib/fleet/provider.js +12 -11
- package/lib/fleet/routes.js +11 -2
- package/lib/nodes/peering.js +85 -7
- package/lib/settings/routes.js +214 -61
- package/package.json +1 -1
- package/frontend/dist/assets/index-UAVxf6SE.js +0 -90
- package/frontend/dist/assets/index-U_e8auCM.css +0 -32
package/lib/nodes/peering.js
CHANGED
|
@@ -40,13 +40,44 @@ function encodePairing(data) {
|
|
|
40
40
|
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Allowlist rigorosa per versione (caps). v1: solo {v,instanceId,port,label,invite}.
|
|
44
|
+
// v2 aggiunge name (slug suggerito), ssh (target/alias), sshPort?: routing non
|
|
45
|
+
// segreto. NESSUN campo segreto è ammesso oltre l'invite one-time: niente
|
|
46
|
+
// identityFile, chiave privata, API key, bearer UI. Unknown field -> null.
|
|
47
|
+
const PAIRING_V1_KEYS = new Set(['v', 'instanceId', 'port', 'label', 'invite']);
|
|
48
|
+
const PAIRING_V2_KEYS = new Set(['v', 'instanceId', 'port', 'label', 'invite', 'name', 'ssh', 'sshPort']);
|
|
49
|
+
|
|
43
50
|
function decodePairing(value) {
|
|
51
|
+
let x;
|
|
44
52
|
try {
|
|
45
|
-
|
|
46
|
-
if (!x || x.v !== 1 || !store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
|
|
47
|
-
|| !store.validToken(x.invite) || typeof x.label !== 'string') return null;
|
|
48
|
-
return x;
|
|
53
|
+
x = JSON.parse(Buffer.from(String(value || ''), 'base64url').toString('utf8'));
|
|
49
54
|
} catch (_) { return null; }
|
|
55
|
+
if (!x || typeof x !== 'object' || Array.isArray(x)) return null;
|
|
56
|
+
const allowed = x.v === 1 ? PAIRING_V1_KEYS : x.v === 2 ? PAIRING_V2_KEYS : null;
|
|
57
|
+
if (!allowed) return null;
|
|
58
|
+
for (const k of Object.keys(x)) if (!allowed.has(k)) return null; // strict: unknown -> null
|
|
59
|
+
// core obbligatorio (entrambe le versioni)
|
|
60
|
+
if (!store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
|
|
61
|
+
|| !store.validToken(x.invite) || typeof x.label !== 'string') return null;
|
|
62
|
+
const out = { v: x.v, instanceId: x.instanceId, port: x.port, label: x.label, invite: x.invite };
|
|
63
|
+
if (x.v === 2) {
|
|
64
|
+
if (x.name !== undefined) {
|
|
65
|
+
if (typeof x.name !== 'string' || !store.NODE_NAME_RE.test(x.name)) return null;
|
|
66
|
+
out.name = x.name;
|
|
67
|
+
}
|
|
68
|
+
if (x.ssh !== undefined) {
|
|
69
|
+
// target/alias SSH (user@host o Host alias); MAI secret. parseSshTarget
|
|
70
|
+
// rifiuta whitespace/control/leading '-' (argv-safe, non diventa flag ssh).
|
|
71
|
+
const ssh = typeof x.ssh === 'string' ? store.parseSshTarget(x.ssh) : null;
|
|
72
|
+
if (!ssh) return null;
|
|
73
|
+
out.ssh = ssh.value;
|
|
74
|
+
}
|
|
75
|
+
if (x.sshPort !== undefined) {
|
|
76
|
+
if (!store.isPort(x.sshPort)) return null;
|
|
77
|
+
out.sshPort = x.sshPort;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
50
81
|
}
|
|
51
82
|
|
|
52
83
|
function parsePairingUrl(value) {
|
|
@@ -57,14 +88,35 @@ function parsePairingUrl(value) {
|
|
|
57
88
|
} catch (_) { return null; }
|
|
58
89
|
}
|
|
59
90
|
|
|
60
|
-
function createInvite({
|
|
91
|
+
function createInvite({
|
|
92
|
+
invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
|
|
93
|
+
ssh, sshPort, name,
|
|
94
|
+
} = {}) {
|
|
61
95
|
const invite = randomBytes(32).toString('base64url');
|
|
62
96
|
const expiresAt = now + INVITE_TTL_MS;
|
|
63
97
|
const rows = readInvites(invitesPath, now);
|
|
64
98
|
rows.push({ hash: crypto.createHash('sha256').update(invite).digest('hex'), expiresAt, usedAt: null });
|
|
65
99
|
writeInvites(invitesPath, rows.slice(-32));
|
|
66
|
-
const
|
|
67
|
-
|
|
100
|
+
const payload = { v: 1, instanceId, port, label: String(label).slice(0, 64), invite };
|
|
101
|
+
// v2: include Host/alias SSH + slug quando forniti (routing non segreto). Così
|
|
102
|
+
// il ricevente incolla/scansiona UN solo link e ha tutto per precompilare il
|
|
103
|
+
// form. Senza ssh/name resta v1 (backward-compat con link 0.8.x esistenti).
|
|
104
|
+
const sshVal = typeof ssh === 'string' && ssh.trim() ? store.parseSshTarget(ssh.trim()) : null;
|
|
105
|
+
const requestedName = typeof name === 'string' && name.trim() && store.NODE_NAME_RE.test(name.trim())
|
|
106
|
+
? name.trim()
|
|
107
|
+
: '';
|
|
108
|
+
// Un link con routing SSH deve sempre portare anche lo slug. Se il chiamante
|
|
109
|
+
// non lo specifica, lo ricaviamo dalla label del dispositivo: il ricevente non
|
|
110
|
+
// deve compilare altri campi per arrivare a "testa e collega".
|
|
111
|
+
const linkName = requestedName || (sshVal ? store.toSlug(payload.label) : '');
|
|
112
|
+
if (sshVal || linkName) {
|
|
113
|
+
payload.v = 2;
|
|
114
|
+
if (linkName) payload.name = linkName;
|
|
115
|
+
if (sshVal) payload.ssh = sshVal.value;
|
|
116
|
+
if (sshVal && store.isPort(sshPort)) payload.sshPort = sshPort;
|
|
117
|
+
}
|
|
118
|
+
const pair = encodePairing(payload);
|
|
119
|
+
return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt, version: payload.v };
|
|
68
120
|
}
|
|
69
121
|
|
|
70
122
|
function consumeInvite({ invitesPath, invite, now = Date.now() }) {
|
|
@@ -109,8 +161,34 @@ function consumePending({ pendingPath, credential, now = Date.now() }) {
|
|
|
109
161
|
return found;
|
|
110
162
|
}
|
|
111
163
|
|
|
164
|
+
// Probe di trasporto del tunnel -L provvisorio PRIMA di consumare l'invite
|
|
165
|
+
// one-time: qualunque risposta HTTP dal peer attraverso la forward (anche un
|
|
166
|
+
// 401 su /federation/health senza credenziali) dimostra che ssh+forward sono
|
|
167
|
+
// vivi; un errore di rete no. Sostituisce lo sleep fisso 900ms: bounded, con
|
|
168
|
+
// sleep iniettabile (deterministico nei test) e timeout per tentativo.
|
|
169
|
+
async function probeTransportReady({ port, fetchImpl = fetch, attempts = 6, timeoutMs = 1500, sleep } = {}) {
|
|
170
|
+
const wait = typeof sleep === 'function' ? sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
171
|
+
let lastError = '';
|
|
172
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
173
|
+
let timer;
|
|
174
|
+
try {
|
|
175
|
+
const ctrl = new AbortController();
|
|
176
|
+
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
177
|
+
await fetchImpl(`http://127.0.0.1:${port}/federation/health`, { signal: ctrl.signal });
|
|
178
|
+
return { ready: true, attempts: i + 1 };
|
|
179
|
+
} catch (e) {
|
|
180
|
+
lastError = String((e && e.message) || e);
|
|
181
|
+
} finally {
|
|
182
|
+
if (timer) clearTimeout(timer);
|
|
183
|
+
}
|
|
184
|
+
if (i < attempts - 1) await wait(250 * (i + 1));
|
|
185
|
+
}
|
|
186
|
+
return { ready: false, attempts, lastError };
|
|
187
|
+
}
|
|
188
|
+
|
|
112
189
|
module.exports = {
|
|
113
190
|
INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
|
|
114
191
|
readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
|
|
115
192
|
createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
|
|
193
|
+
probeTransportReady,
|
|
116
194
|
};
|
package/lib/settings/routes.js
CHANGED
|
@@ -46,6 +46,7 @@ const nodesStore = require('../nodes/store.js');
|
|
|
46
46
|
const nodesCmds = require('../nodes/commands.js');
|
|
47
47
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
48
48
|
const peering = require('../nodes/peering.js');
|
|
49
|
+
const { probeHealth } = require('../proxy/federation.js');
|
|
49
50
|
const { rotateToken } = require('../auth/token.js');
|
|
50
51
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
51
52
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
@@ -289,74 +290,219 @@ function settingsRoutes(deps = {}) {
|
|
|
289
290
|
// persisted hashed, 0600, for ten minutes only. Il `label` nel payload e'
|
|
290
291
|
// l'etichetta umana con cui il peer vedra' questo dispositivo: default sensato
|
|
291
292
|
// (mai 'localhost' crudo), sovrascrivibile dal form.
|
|
293
|
+
// v2: il creatore puo' includere l'Host/alias SSH raggiungibile (+ slug + porta
|
|
294
|
+
// SSH opzionale) cosicché il ricevente incolla/scansiona UN solo link e precompila
|
|
295
|
+
// tutto. NIENTE segreti: solo routing. L'invite one-time resta l'unica credenziale.
|
|
292
296
|
r.post('/peering/invite', mutGate, (req, res) => {
|
|
293
297
|
try {
|
|
294
|
-
const
|
|
298
|
+
const b = req.body || {};
|
|
299
|
+
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
300
|
+
const extra = {};
|
|
301
|
+
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
302
|
+
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
303
|
+
if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
|
|
304
|
+
extra.ssh = ssh.value;
|
|
305
|
+
}
|
|
306
|
+
if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
|
|
307
|
+
if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
|
|
308
|
+
extra.sshPort = Number(b.sshPort);
|
|
309
|
+
}
|
|
310
|
+
if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
|
|
311
|
+
if (typeof b.name === 'string' && b.name.trim()) {
|
|
312
|
+
if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
|
|
313
|
+
extra.name = b.name.trim();
|
|
314
|
+
}
|
|
315
|
+
if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
|
|
295
316
|
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
296
317
|
send(res, 200, peering.createInvite({
|
|
297
|
-
invitesPath, instanceId: st.nodeId, port: cfg.port, label,
|
|
318
|
+
invitesPath, instanceId: st.nodeId, port: cfg.port, label, ...extra,
|
|
298
319
|
}));
|
|
299
320
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
300
321
|
});
|
|
301
322
|
|
|
302
|
-
// Hydra join
|
|
303
|
-
//
|
|
323
|
+
// Hydra join in stadi espliciti: la PWA riceve un contratto strutturato
|
|
324
|
+
// {error, code, stage, detail?, hint?, retryable?} — MAI credenziali, token,
|
|
325
|
+
// header Authorization o contenuto di chiavi nel payload. Stadi distinti:
|
|
326
|
+
// validation | conflict | ssh-start | ssh-ready | join | tunnel-final |
|
|
327
|
+
// confirm | health (+ internal per gli inattesi)
|
|
328
|
+
// Lo sleep fisso 900ms e' sostituito da un probe di readiness bounded
|
|
329
|
+
// (peering.probeTransportReady) eseguito PRIMA di consumare l'invite one-time;
|
|
330
|
+
// una risposta join ambigua (rete morta dopo l'invio) non viene mai rigiocata.
|
|
331
|
+
// Su qualunque fallimento post-provisioning il rollback locale/remoto gira
|
|
332
|
+
// esattamente una volta e lo stage fallito arriva al client.
|
|
304
333
|
r.post('/nodes/pair', mutGate, async (req, res) => {
|
|
305
334
|
const b = req.body || {};
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
335
|
+
const redactSecrets = (s) => String(s || '')
|
|
336
|
+
.replace(/Bearer\s+\S+/gi, 'Bearer ***')
|
|
337
|
+
.replace(/[A-Za-z0-9_-]{40,}/g, '***');
|
|
338
|
+
const fail = (status, stage, code, detail, extra = {}) => send(res, status, {
|
|
339
|
+
error: redactSecrets(detail), stage, code, detail: redactSecrets(detail), retryable: false, ...extra,
|
|
340
|
+
});
|
|
341
|
+
if (!validName(b.name)) return fail(400, 'validation', 'bad-name', 'name non valido (slug a-z, 0-9, -, max 32)', { retryable: true });
|
|
342
|
+
if (!nodesStore.parseSshTarget(b.ssh)) return fail(400, 'validation', 'bad-ssh', 'alias SSH non valido (atteso user@host o Host alias)', { retryable: true });
|
|
309
343
|
const pair = peering.parsePairingUrl(b.pairingUrl);
|
|
310
|
-
if (!pair) return
|
|
311
|
-
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return
|
|
312
|
-
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return
|
|
313
|
-
if (b.label !== undefined && !nodesStore.validLabel(b.label)) return
|
|
314
|
-
if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return
|
|
344
|
+
if (!pair) return fail(400, 'validation', 'bad-link', 'link di pairing non valido o corrotto', { retryable: true, hint: 'rigenera il link sul dispositivo che invita' });
|
|
345
|
+
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return fail(400, 'validation', 'bad-ssh-port', 'sshPort non valida (1..65535)', { retryable: true });
|
|
346
|
+
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return fail(400, 'validation', 'bad-identity-file', 'identityFile non valido (path assoluto)', { retryable: true });
|
|
347
|
+
if (b.label !== undefined && !nodesStore.validLabel(b.label)) return fail(400, 'validation', 'bad-label', 'label non valida (max 64 char, niente a capo)', { retryable: true });
|
|
348
|
+
if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return fail(400, 'validation', 'bad-label', 'localLabel non valida (max 64 char, niente a capo)', { retryable: true });
|
|
315
349
|
// label umana del peer come lo vedro' io (display); se assente usa lo slug.
|
|
316
350
|
const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
|
|
317
351
|
// etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
|
|
318
352
|
const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
|
|
353
|
+
const fetchImpl = seams.fetchImpl || fetch;
|
|
354
|
+
const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
|
|
355
|
+
const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
|
|
356
|
+
? seams.pairRequestTimeoutMs : 6000;
|
|
357
|
+
// Every protocol request must terminate. Readiness and federation health
|
|
358
|
+
// already have their own bounded probes; join/confirm/cancel use this
|
|
359
|
+
// wrapper so a half-open peer cannot leave the PWA waiting forever.
|
|
360
|
+
const pairFetch = async (url, opts = {}, timeoutMs = requestTimeoutMs) => {
|
|
361
|
+
const ctrl = new AbortController();
|
|
362
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
363
|
+
try { return await fetchImpl(url, { ...opts, signal: ctrl.signal }); }
|
|
364
|
+
finally { clearTimeout(timer); }
|
|
365
|
+
};
|
|
366
|
+
|
|
319
367
|
let provisionalPort = null;
|
|
320
368
|
let rollbackCredential = null;
|
|
321
369
|
let created = false;
|
|
370
|
+
let rolledBack = false;
|
|
371
|
+
// Rollback locale/remoto ESATTAMENTE una volta, best-effort: cancella la
|
|
372
|
+
// credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
|
|
373
|
+
const rollback = async () => {
|
|
374
|
+
if (rolledBack) return; rolledBack = true;
|
|
375
|
+
if (rollbackCredential && provisionalPort) {
|
|
376
|
+
try {
|
|
377
|
+
await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
378
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
379
|
+
body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
|
|
380
|
+
}, Math.min(requestTimeoutMs, 3000));
|
|
381
|
+
} catch (_) { /* best-effort */ }
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
385
|
+
const n = st && nodesStore.getNode(st, b.name);
|
|
386
|
+
if (n && created) {
|
|
387
|
+
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
388
|
+
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
|
|
389
|
+
}
|
|
390
|
+
} catch (_) { /* best-effort */ }
|
|
391
|
+
};
|
|
392
|
+
const failRolledBack = async (status, stage, code, detail, extra = {}) => {
|
|
393
|
+
await rollback();
|
|
394
|
+
return fail(status, stage, code, detail, extra);
|
|
395
|
+
};
|
|
396
|
+
|
|
322
397
|
try {
|
|
398
|
+
// --- conflict: il nome non deve gia' esistere --------------------------
|
|
323
399
|
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
400
|
+
if (st.nodes.some((n) => n.name === b.name)) {
|
|
401
|
+
return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
|
|
402
|
+
retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
if (st.nodeId === pair.instanceId) {
|
|
406
|
+
return fail(409, 'conflict', 'self-pairing', 'il link appartiene a questa stessa installazione', {
|
|
407
|
+
hint: 'genera il link sul nodo remoto che vuoi collegare',
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
const knownPeer = st.nodes.find((n) => n.nodeId === pair.instanceId);
|
|
411
|
+
if (knownPeer) {
|
|
412
|
+
return fail(409, 'conflict', 'peer-exists', `questa installazione e' gia' collegata come "${knownPeer.name}"`, {
|
|
413
|
+
hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
324
416
|
const localPort = nodesCmds.assignLocalPort(st);
|
|
325
417
|
provisionalPort = localPort;
|
|
326
418
|
const acceptToken = crypto.randomBytes(32).toString('base64url');
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
419
|
+
try {
|
|
420
|
+
st = nodesStore.addNode(st, {
|
|
421
|
+
name: b.name, ssh: b.ssh, sshPort: b.sshPort,
|
|
422
|
+
remotePort: pair.port, localPort, identityFile: b.identityFile,
|
|
423
|
+
transport: 'auto', autostart: true, visibility: 'network',
|
|
424
|
+
direction: 'outbound', acceptToken, label: peerLabel,
|
|
425
|
+
});
|
|
426
|
+
} catch (e) {
|
|
427
|
+
const msg = String((e && e.message) || e);
|
|
428
|
+
const isDup = msg.includes('duplicato') || msg.includes('self-reference');
|
|
429
|
+
return fail(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
|
|
430
|
+
}
|
|
333
431
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
334
432
|
created = true;
|
|
335
433
|
const node = nodesStore.getNode(st, b.name);
|
|
434
|
+
|
|
435
|
+
// --- ssh-start: supervisor del tunnel -L provvisorio --------------------
|
|
336
436
|
const started = nodesTunnel.startForward({
|
|
337
437
|
home, node, localAppPort: cfg.port,
|
|
338
438
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
339
439
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
340
440
|
});
|
|
341
|
-
if (!started.started && started.reason !== 'already running')
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
441
|
+
if (!started.started && started.reason !== 'already running') {
|
|
442
|
+
return failRolledBack(502, 'ssh-start', 'tunnel-start-failed', started.reason || 'avvio del tunnel SSH fallito', {
|
|
443
|
+
retryable: true,
|
|
444
|
+
hint: 'verifica ssh e target/alias su questo dispositivo; il link NON e\' stato consumato e puoi riprovare',
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
|
|
449
|
+
const ready = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
|
|
450
|
+
if (!ready.ready) {
|
|
451
|
+
return failRolledBack(502, 'ssh-ready', 'transport-not-ready',
|
|
452
|
+
`il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
|
|
453
|
+
retryable: true,
|
|
454
|
+
hint: 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
|
|
459
|
+
let jr;
|
|
460
|
+
try {
|
|
461
|
+
jr = await pairFetch(`http://127.0.0.1:${localPort}/pair/join`, {
|
|
462
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
463
|
+
body: JSON.stringify({
|
|
464
|
+
invite: pair.invite,
|
|
465
|
+
instanceId: st.nodeId,
|
|
466
|
+
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
467
|
+
label: localLabel,
|
|
468
|
+
port: cfg.port,
|
|
469
|
+
acceptToken,
|
|
470
|
+
}),
|
|
471
|
+
});
|
|
472
|
+
} catch (e) {
|
|
473
|
+
// Risposta persa DOPO l'invio: l'invite potrebbe essere stato consumato.
|
|
474
|
+
// Un join ambiguo non si rigioca mai.
|
|
475
|
+
return failRolledBack(502, 'join', 'join-ambiguous',
|
|
476
|
+
'risposta di join persa: l\'invito potrebbe essere gia\' stato consumato', {
|
|
477
|
+
hint: 'rigenera un nuovo link sul dispositivo che invita e riprova',
|
|
478
|
+
});
|
|
479
|
+
}
|
|
356
480
|
const joined = await jr.json().catch(() => ({}));
|
|
357
|
-
if (
|
|
358
|
-
|
|
481
|
+
if (jr.status === 410) {
|
|
482
|
+
return failRolledBack(502, 'join', 'invite-expired', 'invito scaduto o gia\' usato (one-time)', {
|
|
483
|
+
hint: 'rigenera un nuovo link sul dispositivo che invita',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (!jr.ok) {
|
|
487
|
+
return failRolledBack(502, 'join', 'join-rejected', joined.error || `join rifiutato dal peer (HTTP ${jr.status})`, {
|
|
488
|
+
hint: 'rigenera un nuovo link e riprova',
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
|
|
492
|
+
|| !nodesStore.NODE_ID_RE.test(joined.instanceId)) {
|
|
493
|
+
return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
|
|
494
|
+
hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
|
|
495
|
+
});
|
|
496
|
+
}
|
|
359
497
|
rollbackCredential = joined.credential;
|
|
498
|
+
if (joined.instanceId !== pair.instanceId) {
|
|
499
|
+
return failRolledBack(502, 'join', 'peer-identity-mismatch',
|
|
500
|
+
'l\'identita\' del peer raggiunto non coincide con quella contenuta nel link', {
|
|
501
|
+
hint: 'controlla che il target SSH punti al nodo che ha generato il link',
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// --- tunnel-final: riavvio con la spec negoziata (-R inclusa) -----------
|
|
360
506
|
st = nodesStore.loadOrInitStore(nodesPath);
|
|
361
507
|
st = nodesStore.updateNode(st, b.name, {
|
|
362
508
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
@@ -368,44 +514,51 @@ function settingsRoutes(deps = {}) {
|
|
|
368
514
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
369
515
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
370
516
|
});
|
|
371
|
-
if (!finalStart.started && finalStart.reason !== 'already running')
|
|
372
|
-
|
|
373
|
-
|
|
517
|
+
if (!finalStart.started && finalStart.reason !== 'already running') {
|
|
518
|
+
return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
|
|
519
|
+
}
|
|
520
|
+
const readyFinal = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
|
|
521
|
+
if (!readyFinal.ready) {
|
|
522
|
+
return failRolledBack(502, 'tunnel-final', 'transport-not-ready',
|
|
523
|
+
`il tunnel negoziato non risponde (${readyFinal.attempts} tentativi)`, {});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// --- confirm: idempotente lato peer -> bounded retry ---------------------
|
|
374
527
|
let confirmed = null;
|
|
375
528
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
376
529
|
try {
|
|
377
|
-
confirmed = await
|
|
530
|
+
confirmed = await pairFetch(`http://127.0.0.1:${localPort}/pair/confirm`, {
|
|
378
531
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
379
532
|
body: JSON.stringify({ credential: joined.credential }),
|
|
380
|
-
});
|
|
533
|
+
}, Math.min(requestTimeoutMs, 3500));
|
|
381
534
|
if (confirmed.ok) break;
|
|
382
|
-
} catch (_) {}
|
|
383
|
-
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
|
|
535
|
+
} catch (_) { /* retry: confirm e' idempotente lato peer */ }
|
|
536
|
+
if (attempt < 2) await (sleep ? sleep() : new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))));
|
|
384
537
|
}
|
|
385
538
|
if (!confirmed || !confirmed.ok) {
|
|
386
539
|
const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
|
|
387
|
-
|
|
540
|
+
return failRolledBack(502, 'confirm', 'confirm-failed',
|
|
541
|
+
x.error || `conferma pairing fallita (HTTP ${confirmed ? confirmed.status : 'irraggiungibile'})`, {
|
|
542
|
+
hint: 'rigenera un nuovo link e riprova',
|
|
543
|
+
});
|
|
388
544
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
545
|
+
|
|
546
|
+
// --- health: federazione AUTENTICATA verificata prima di paired:true ----
|
|
547
|
+
const health = await probeHealth({
|
|
548
|
+
port: localPort, token: joined.credential, expectedInstanceId: joined.instanceId,
|
|
549
|
+
fetchImpl, now: Date.now(),
|
|
550
|
+
});
|
|
551
|
+
if (!health || health.status !== 'healthy') {
|
|
552
|
+
return failRolledBack(502, 'health', 'federation-health-failed',
|
|
553
|
+
(health && health.detail) || 'health federato non verificabile dopo la conferma', {
|
|
554
|
+
hint: 'pairing annullato e ripulito: rigenera il link e riprova',
|
|
397
555
|
});
|
|
398
|
-
} catch (_) {}
|
|
399
556
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
|
|
406
|
-
}
|
|
407
|
-
} catch (_) {}
|
|
408
|
-
send(res, 502, { error: String(e.message || e) });
|
|
557
|
+
|
|
558
|
+
send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
|
|
559
|
+
} catch (e) {
|
|
560
|
+
await rollback();
|
|
561
|
+
fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
|
|
409
562
|
}
|
|
410
563
|
});
|
|
411
564
|
|