@mmmbuto/nexuscrew 0.8.6 → 0.8.9
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 +68 -20
- package/frontend/dist/assets/index-9Drd8g0k.css +32 -0
- package/frontend/dist/assets/index-DTmT7yhV.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/config.js +6 -0
- package/lib/fleet/builtin.js +57 -4
- package/lib/fleet/routes.js +13 -2
- package/lib/nodes/health.js +13 -2
- package/lib/nodes/peering.js +29 -2
- package/lib/nodes/store.js +9 -2
- package/lib/proxy/federation.js +14 -4
- package/lib/server.js +13 -3
- package/lib/settings/routes.js +248 -67
- package/lib/update/core.js +157 -0
- package/lib/update/manager.js +245 -0
- package/lib/update/runner.js +144 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-CEFh0hI4.js +0 -90
- package/frontend/dist/assets/index-kX5IUxt4.css +0 -32
package/lib/settings/routes.js
CHANGED
|
@@ -46,15 +46,17 @@ 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');
|
|
52
53
|
const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
|
|
53
54
|
const { configJsonPath } = require('../config.js');
|
|
54
55
|
const VERSION = require('../../package.json').version;
|
|
56
|
+
const { scrubError } = require('../update/core.js');
|
|
55
57
|
|
|
56
58
|
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
57
|
-
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
|
|
59
|
+
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
|
|
58
60
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
59
61
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
60
62
|
const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
|
|
@@ -140,6 +142,7 @@ function settingsRoutes(deps = {}) {
|
|
|
140
142
|
// scrive solo il file (come prima), senza reload in-memory.
|
|
141
143
|
const tokenStore = deps.tokenStore || null;
|
|
142
144
|
const closeSessions = deps.closeSessions || null;
|
|
145
|
+
const updater = deps.updater || null;
|
|
143
146
|
const home = cfg.home || os.homedir();
|
|
144
147
|
const configDir = cfg.configDir || path.join(home, '.nexuscrew');
|
|
145
148
|
// configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
|
|
@@ -190,6 +193,7 @@ function settingsRoutes(deps = {}) {
|
|
|
190
193
|
active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
|
|
191
194
|
boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
|
|
192
195
|
};
|
|
196
|
+
const updateStatus = updater && typeof updater.status === 'function' ? updater.status() : null;
|
|
193
197
|
const out = {
|
|
194
198
|
roles: readRoles(configPath),
|
|
195
199
|
firstRun,
|
|
@@ -197,10 +201,12 @@ function settingsRoutes(deps = {}) {
|
|
|
197
201
|
platform,
|
|
198
202
|
service,
|
|
199
203
|
version: VERSION,
|
|
204
|
+
autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
|
|
200
205
|
// Nome dispositivo proposto per i form di pairing (etichetta umana, non
|
|
201
206
|
// lo slug). La UI lo precompila e lascia editing libero.
|
|
202
207
|
deviceName: defaultDeviceName(),
|
|
203
208
|
};
|
|
209
|
+
if (updateStatus) out.update = updateStatus;
|
|
204
210
|
// rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
|
|
205
211
|
// la si prende comunque SOLO dalla vista redatta.
|
|
206
212
|
const st = nodesStore.loadStore(nodesPath);
|
|
@@ -220,10 +226,10 @@ function settingsRoutes(deps = {}) {
|
|
|
220
226
|
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
221
227
|
}
|
|
222
228
|
for (const k of Object.keys(b)) {
|
|
223
|
-
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
|
|
229
|
+
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
|
|
224
230
|
}
|
|
225
231
|
if (Object.keys(b).length === 0) {
|
|
226
|
-
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
|
|
232
|
+
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
|
|
227
233
|
}
|
|
228
234
|
if (b.roles !== undefined) {
|
|
229
235
|
if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
|
|
@@ -240,6 +246,9 @@ function settingsRoutes(deps = {}) {
|
|
|
240
246
|
if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
|
|
241
247
|
return send(res, 400, { error: 'wizardDone deve essere boolean' });
|
|
242
248
|
}
|
|
249
|
+
if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
|
|
250
|
+
return send(res, 400, { error: 'autoUpdate deve essere boolean' });
|
|
251
|
+
}
|
|
243
252
|
|
|
244
253
|
// merge sul config esistente (preserva le chiavi non gestite qui)
|
|
245
254
|
const { cfg: current } = readConfigFile(configPath);
|
|
@@ -250,11 +259,15 @@ function settingsRoutes(deps = {}) {
|
|
|
250
259
|
}
|
|
251
260
|
if (b.port !== undefined) next.port = b.port;
|
|
252
261
|
if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
|
|
262
|
+
if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
|
|
253
263
|
atomicWriteConfig(configPath, next);
|
|
264
|
+
if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
|
|
265
|
+
updater.setEnabled(b.autoUpdate);
|
|
266
|
+
}
|
|
254
267
|
|
|
255
268
|
const out = {
|
|
256
269
|
saved: true,
|
|
257
|
-
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
|
|
270
|
+
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone, autoUpdate: next.autoUpdate !== false },
|
|
258
271
|
};
|
|
259
272
|
// Il server legge la porta SOLO allo startup: il cambio vale al prossimo
|
|
260
273
|
// restart (contratto dichiarato nella risposta, la UI avvisa).
|
|
@@ -265,6 +278,20 @@ function settingsRoutes(deps = {}) {
|
|
|
265
278
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
266
279
|
});
|
|
267
280
|
|
|
281
|
+
// Auto-update npm: check e apply sono autenticati, READONLY-gated e non
|
|
282
|
+
// accettano mai package/versione dal browser. Il manager usa esclusivamente
|
|
283
|
+
// @mmmbuto/nexuscrew@latest, confronta semver e installa la versione esatta.
|
|
284
|
+
r.post('/update/check', mutGate, async (_req, res) => {
|
|
285
|
+
if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
|
|
286
|
+
try { send(res, 200, await updater.check()); }
|
|
287
|
+
catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
|
|
288
|
+
});
|
|
289
|
+
r.post('/update/apply', mutGate, async (_req, res) => {
|
|
290
|
+
if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
|
|
291
|
+
try { send(res, 202, await updater.apply()); }
|
|
292
|
+
catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
|
|
293
|
+
});
|
|
294
|
+
|
|
268
295
|
// --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
|
|
269
296
|
// Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
|
|
270
297
|
// WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
|
|
@@ -296,90 +323,231 @@ function settingsRoutes(deps = {}) {
|
|
|
296
323
|
try {
|
|
297
324
|
const b = req.body || {};
|
|
298
325
|
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
326
|
+
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
327
|
+
const rendezvous = st.rendezvous || null;
|
|
299
328
|
const extra = {};
|
|
300
329
|
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
301
330
|
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
302
331
|
if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
|
|
303
332
|
extra.ssh = ssh.value;
|
|
333
|
+
} else if (rendezvous && rendezvous.ssh) {
|
|
334
|
+
// Zero-field creator path: the rendezvous SSH target is reachable by
|
|
335
|
+
// definition. Its publishedPort is the REMOTE NEXUSCREW HTTP port,
|
|
336
|
+
// never the OpenSSH transport port.
|
|
337
|
+
extra.ssh = rendezvous.ssh;
|
|
304
338
|
}
|
|
305
339
|
if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
|
|
306
340
|
if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
|
|
307
341
|
extra.sshPort = Number(b.sshPort);
|
|
308
342
|
}
|
|
309
343
|
if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
|
|
344
|
+
let remotePort = cfg.port;
|
|
345
|
+
if (b.remotePort !== undefined && b.remotePort !== null && b.remotePort !== '') {
|
|
346
|
+
if (!nodesStore.isPort(Number(b.remotePort))) return send(res, 400, { error: 'remotePort non valida (1..65535)' });
|
|
347
|
+
remotePort = Number(b.remotePort);
|
|
348
|
+
} else if (rendezvous && extra.ssh === rendezvous.ssh) {
|
|
349
|
+
remotePort = rendezvous.publishedPort;
|
|
350
|
+
}
|
|
310
351
|
if (typeof b.name === 'string' && b.name.trim()) {
|
|
311
352
|
if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
|
|
312
353
|
extra.name = b.name.trim();
|
|
313
354
|
}
|
|
314
355
|
if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
|
|
315
|
-
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
316
356
|
send(res, 200, peering.createInvite({
|
|
317
|
-
invitesPath, instanceId: st.nodeId, port: cfg.port, label, ...extra,
|
|
357
|
+
invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: cfg.port, label, ...extra,
|
|
318
358
|
}));
|
|
319
359
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
320
360
|
});
|
|
321
361
|
|
|
322
|
-
// Hydra join
|
|
323
|
-
//
|
|
362
|
+
// Hydra join in stadi espliciti: la PWA riceve un contratto strutturato
|
|
363
|
+
// {error, code, stage, detail?, hint?, retryable?} — MAI credenziali, token,
|
|
364
|
+
// header Authorization o contenuto di chiavi nel payload. Stadi distinti:
|
|
365
|
+
// validation | conflict | ssh-start | ssh-ready | join | tunnel-final |
|
|
366
|
+
// confirm | health (+ internal per gli inattesi)
|
|
367
|
+
// Lo sleep fisso 900ms e' sostituito da un probe di readiness bounded
|
|
368
|
+
// (peering.probeTransportReady) eseguito PRIMA di consumare l'invite one-time;
|
|
369
|
+
// una risposta join ambigua (rete morta dopo l'invio) non viene mai rigiocata.
|
|
370
|
+
// Su qualunque fallimento post-provisioning il rollback locale/remoto gira
|
|
371
|
+
// esattamente una volta e lo stage fallito arriva al client.
|
|
324
372
|
r.post('/nodes/pair', mutGate, async (req, res) => {
|
|
325
373
|
const b = req.body || {};
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
374
|
+
const redactSecrets = (s) => String(s || '')
|
|
375
|
+
.replace(/Bearer\s+\S+/gi, 'Bearer ***')
|
|
376
|
+
.replace(/[A-Za-z0-9_-]{40,}/g, '***');
|
|
377
|
+
const fail = (status, stage, code, detail, extra = {}) => send(res, status, {
|
|
378
|
+
error: redactSecrets(detail), stage, code, detail: redactSecrets(detail), retryable: false, ...extra,
|
|
379
|
+
});
|
|
380
|
+
if (!validName(b.name)) return fail(400, 'validation', 'bad-name', 'name non valido (slug a-z, 0-9, -, max 32)', { retryable: true });
|
|
381
|
+
if (!nodesStore.parseSshTarget(b.ssh)) return fail(400, 'validation', 'bad-ssh', 'alias SSH non valido (atteso user@host o Host alias)', { retryable: true });
|
|
329
382
|
const pair = peering.parsePairingUrl(b.pairingUrl);
|
|
330
|
-
if (!pair) return
|
|
331
|
-
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return
|
|
332
|
-
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return
|
|
333
|
-
if (b.label !== undefined && !nodesStore.validLabel(b.label)) return
|
|
334
|
-
if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return
|
|
383
|
+
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' });
|
|
384
|
+
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return fail(400, 'validation', 'bad-ssh-port', 'sshPort non valida (1..65535)', { retryable: true });
|
|
385
|
+
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return fail(400, 'validation', 'bad-identity-file', 'identityFile non valido (path assoluto)', { retryable: true });
|
|
386
|
+
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 });
|
|
387
|
+
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 });
|
|
335
388
|
// label umana del peer come lo vedro' io (display); se assente usa lo slug.
|
|
336
389
|
const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
|
|
337
390
|
// etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
|
|
338
391
|
const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
|
|
392
|
+
const fetchImpl = seams.fetchImpl || fetch;
|
|
393
|
+
const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
|
|
394
|
+
const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
|
|
395
|
+
? seams.pairRequestTimeoutMs : 6000;
|
|
396
|
+
// Every protocol request must terminate. Readiness and federation health
|
|
397
|
+
// already have their own bounded probes; join/confirm/cancel use this
|
|
398
|
+
// wrapper so a half-open peer cannot leave the PWA waiting forever.
|
|
399
|
+
const pairFetch = async (url, opts = {}, timeoutMs = requestTimeoutMs) => {
|
|
400
|
+
const ctrl = new AbortController();
|
|
401
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
402
|
+
try { return await fetchImpl(url, { ...opts, signal: ctrl.signal }); }
|
|
403
|
+
finally { clearTimeout(timer); }
|
|
404
|
+
};
|
|
405
|
+
|
|
339
406
|
let provisionalPort = null;
|
|
340
407
|
let rollbackCredential = null;
|
|
341
408
|
let created = false;
|
|
409
|
+
let rolledBack = false;
|
|
410
|
+
// Rollback locale/remoto ESATTAMENTE una volta, best-effort: cancella la
|
|
411
|
+
// credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
|
|
412
|
+
const rollback = async () => {
|
|
413
|
+
if (rolledBack) return; rolledBack = true;
|
|
414
|
+
if (rollbackCredential && provisionalPort) {
|
|
415
|
+
try {
|
|
416
|
+
await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
417
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
418
|
+
body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
|
|
419
|
+
}, Math.min(requestTimeoutMs, 3000));
|
|
420
|
+
} catch (_) { /* best-effort */ }
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
424
|
+
const n = st && nodesStore.getNode(st, b.name);
|
|
425
|
+
if (n && created) {
|
|
426
|
+
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
427
|
+
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
|
|
428
|
+
}
|
|
429
|
+
} catch (_) { /* best-effort */ }
|
|
430
|
+
};
|
|
431
|
+
const failRolledBack = async (status, stage, code, detail, extra = {}) => {
|
|
432
|
+
await rollback();
|
|
433
|
+
return fail(status, stage, code, detail, extra);
|
|
434
|
+
};
|
|
435
|
+
|
|
342
436
|
try {
|
|
437
|
+
// --- conflict: il nome non deve gia' esistere --------------------------
|
|
343
438
|
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
439
|
+
if (st.nodes.some((n) => n.name === b.name)) {
|
|
440
|
+
return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
|
|
441
|
+
retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (st.nodeId === pair.instanceId) {
|
|
445
|
+
return fail(409, 'conflict', 'self-pairing', 'il link appartiene a questa stessa installazione', {
|
|
446
|
+
hint: 'genera il link sul nodo remoto che vuoi collegare',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
const knownPeer = st.nodes.find((n) => n.nodeId === pair.instanceId);
|
|
450
|
+
if (knownPeer) {
|
|
451
|
+
return fail(409, 'conflict', 'peer-exists', `questa installazione e' gia' collegata come "${knownPeer.name}"`, {
|
|
452
|
+
hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
344
455
|
const localPort = nodesCmds.assignLocalPort(st);
|
|
345
456
|
provisionalPort = localPort;
|
|
346
457
|
const acceptToken = crypto.randomBytes(32).toString('base64url');
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
458
|
+
try {
|
|
459
|
+
st = nodesStore.addNode(st, {
|
|
460
|
+
name: b.name, ssh: b.ssh, sshPort: b.sshPort,
|
|
461
|
+
remotePort: pair.port, localPort, identityFile: b.identityFile,
|
|
462
|
+
transport: 'auto', autostart: true, visibility: 'network',
|
|
463
|
+
direction: 'outbound', acceptToken, label: peerLabel,
|
|
464
|
+
});
|
|
465
|
+
} catch (e) {
|
|
466
|
+
const msg = String((e && e.message) || e);
|
|
467
|
+
const isDup = msg.includes('duplicato') || msg.includes('self-reference');
|
|
468
|
+
return fail(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
|
|
469
|
+
}
|
|
353
470
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
354
471
|
created = true;
|
|
355
472
|
const node = nodesStore.getNode(st, b.name);
|
|
473
|
+
|
|
474
|
+
// --- ssh-start: supervisor del tunnel -L provvisorio --------------------
|
|
356
475
|
const started = nodesTunnel.startForward({
|
|
357
476
|
home, node, localAppPort: cfg.port,
|
|
358
477
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
359
478
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
360
479
|
});
|
|
361
|
-
if (!started.started && started.reason !== 'already running')
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
480
|
+
if (!started.started && started.reason !== 'already running') {
|
|
481
|
+
return failRolledBack(502, 'ssh-start', 'tunnel-start-failed', started.reason || 'avvio del tunnel SSH fallito', {
|
|
482
|
+
retryable: true,
|
|
483
|
+
hint: 'verifica ssh e target/alias su questo dispositivo; il link NON e\' stato consumato e puoi riprovare',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
|
|
488
|
+
const ready = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
|
|
489
|
+
if (!ready.ready) {
|
|
490
|
+
return failRolledBack(502, 'ssh-ready', 'transport-not-ready',
|
|
491
|
+
`il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
|
|
492
|
+
retryable: true,
|
|
493
|
+
hint: 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
|
|
498
|
+
let jr;
|
|
499
|
+
try {
|
|
500
|
+
jr = await pairFetch(`http://127.0.0.1:${localPort}/pair/join`, {
|
|
501
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
502
|
+
body: JSON.stringify({
|
|
503
|
+
invite: pair.invite,
|
|
504
|
+
instanceId: st.nodeId,
|
|
505
|
+
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
506
|
+
label: localLabel,
|
|
507
|
+
port: cfg.port,
|
|
508
|
+
acceptToken,
|
|
509
|
+
roles: readRoles(configPath),
|
|
510
|
+
}),
|
|
511
|
+
});
|
|
512
|
+
} catch (e) {
|
|
513
|
+
// Risposta persa DOPO l'invio: l'invite potrebbe essere stato consumato.
|
|
514
|
+
// Un join ambiguo non si rigioca mai.
|
|
515
|
+
return failRolledBack(502, 'join', 'join-ambiguous',
|
|
516
|
+
'risposta di join persa: l\'invito potrebbe essere gia\' stato consumato', {
|
|
517
|
+
hint: 'rigenera un nuovo link sul dispositivo che invita e riprova',
|
|
518
|
+
});
|
|
519
|
+
}
|
|
376
520
|
const joined = await jr.json().catch(() => ({}));
|
|
377
|
-
if (
|
|
378
|
-
|
|
521
|
+
if (jr.status === 410) {
|
|
522
|
+
return failRolledBack(502, 'join', 'invite-expired', 'invito scaduto o gia\' usato (one-time)', {
|
|
523
|
+
hint: 'rigenera un nuovo link sul dispositivo che invita',
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (!jr.ok) {
|
|
527
|
+
return failRolledBack(502, 'join', 'join-rejected', joined.error || `join rifiutato dal peer (HTTP ${jr.status})`, {
|
|
528
|
+
hint: 'rigenera un nuovo link e riprova',
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
|
|
532
|
+
if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
|
|
533
|
+
|| !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
|
|
534
|
+
return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
|
|
535
|
+
hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
379
538
|
rollbackCredential = joined.credential;
|
|
539
|
+
if (joined.instanceId !== pair.instanceId) {
|
|
540
|
+
return failRolledBack(502, 'join', 'peer-identity-mismatch',
|
|
541
|
+
'l\'identita\' del peer raggiunto non coincide con quella contenuta nel link', {
|
|
542
|
+
hint: 'controlla che il target SSH punti al nodo che ha generato il link',
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --- tunnel-final: riavvio con la spec negoziata (-R inclusa) -----------
|
|
380
547
|
st = nodesStore.loadOrInitStore(nodesPath);
|
|
381
548
|
st = nodesStore.updateNode(st, b.name, {
|
|
382
549
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
550
|
+
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
383
551
|
});
|
|
384
552
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
385
553
|
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
@@ -388,44 +556,51 @@ function settingsRoutes(deps = {}) {
|
|
|
388
556
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
389
557
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
390
558
|
});
|
|
391
|
-
if (!finalStart.started && finalStart.reason !== 'already running')
|
|
392
|
-
|
|
393
|
-
|
|
559
|
+
if (!finalStart.started && finalStart.reason !== 'already running') {
|
|
560
|
+
return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
|
|
561
|
+
}
|
|
562
|
+
const readyFinal = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
|
|
563
|
+
if (!readyFinal.ready) {
|
|
564
|
+
return failRolledBack(502, 'tunnel-final', 'transport-not-ready',
|
|
565
|
+
`il tunnel negoziato non risponde (${readyFinal.attempts} tentativi)`, {});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// --- confirm: idempotente lato peer -> bounded retry ---------------------
|
|
394
569
|
let confirmed = null;
|
|
395
570
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
396
571
|
try {
|
|
397
|
-
confirmed = await
|
|
572
|
+
confirmed = await pairFetch(`http://127.0.0.1:${localPort}/pair/confirm`, {
|
|
398
573
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
399
574
|
body: JSON.stringify({ credential: joined.credential }),
|
|
400
|
-
});
|
|
575
|
+
}, Math.min(requestTimeoutMs, 3500));
|
|
401
576
|
if (confirmed.ok) break;
|
|
402
|
-
} catch (_) {}
|
|
403
|
-
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
|
|
577
|
+
} catch (_) { /* retry: confirm e' idempotente lato peer */ }
|
|
578
|
+
if (attempt < 2) await (sleep ? sleep() : new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))));
|
|
404
579
|
}
|
|
405
580
|
if (!confirmed || !confirmed.ok) {
|
|
406
581
|
const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
|
|
407
|
-
|
|
582
|
+
return failRolledBack(502, 'confirm', 'confirm-failed',
|
|
583
|
+
x.error || `conferma pairing fallita (HTTP ${confirmed ? confirmed.status : 'irraggiungibile'})`, {
|
|
584
|
+
hint: 'rigenera un nuovo link e riprova',
|
|
585
|
+
});
|
|
408
586
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
587
|
+
|
|
588
|
+
// --- health: federazione AUTENTICATA verificata prima di paired:true ----
|
|
589
|
+
const health = await probeHealth({
|
|
590
|
+
port: localPort, token: joined.credential, expectedInstanceId: joined.instanceId,
|
|
591
|
+
fetchImpl, now: Date.now(),
|
|
592
|
+
});
|
|
593
|
+
if (!health || health.status !== 'healthy') {
|
|
594
|
+
return failRolledBack(502, 'health', 'federation-health-failed',
|
|
595
|
+
(health && health.detail) || 'health federato non verificabile dopo la conferma', {
|
|
596
|
+
hint: 'pairing annullato e ripulito: rigenera il link e riprova',
|
|
417
597
|
});
|
|
418
|
-
} catch (_) {}
|
|
419
598
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
|
|
426
|
-
}
|
|
427
|
-
} catch (_) {}
|
|
428
|
-
send(res, 502, { error: String(e.message || e) });
|
|
599
|
+
|
|
600
|
+
send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
|
|
601
|
+
} catch (e) {
|
|
602
|
+
await rollback();
|
|
603
|
+
fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
|
|
429
604
|
}
|
|
430
605
|
});
|
|
431
606
|
|
|
@@ -685,6 +860,7 @@ function settingsRoutes(deps = {}) {
|
|
|
685
860
|
function publicPeeringRoutes(deps = {}) {
|
|
686
861
|
const cfg = deps.cfg || {};
|
|
687
862
|
const home = cfg.home || os.homedir();
|
|
863
|
+
const configPath = cfg.configPath || configJsonPath();
|
|
688
864
|
const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
|
|
689
865
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
690
866
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
@@ -698,8 +874,10 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
698
874
|
recent.push(now); attempts.set(key, recent);
|
|
699
875
|
if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
|
|
700
876
|
const b = req.body || {};
|
|
877
|
+
const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
|
|
701
878
|
if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
|
|
702
|
-
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
879
|
+
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
880
|
+
|| (b.roles !== undefined && !peerRoles)) {
|
|
703
881
|
return res.status(400).json({ error: 'pairing request non valida' });
|
|
704
882
|
}
|
|
705
883
|
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
@@ -715,8 +893,9 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
715
893
|
const credential = peering.createPending({ pendingPath, data: {
|
|
716
894
|
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
717
895
|
label: nodesStore.sanitizeLabel(b.label, name),
|
|
896
|
+
...(peerRoles ? { roles: peerRoles, rolesKnown: true } : { rolesKnown: false }),
|
|
718
897
|
} });
|
|
719
|
-
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
|
|
898
|
+
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
|
|
720
899
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
721
900
|
});
|
|
722
901
|
r.post('/confirm', (req, res) => {
|
|
@@ -736,6 +915,8 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
736
915
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
737
916
|
visibility: 'network', nodeId: pending.instanceId,
|
|
738
917
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
918
|
+
...(pending.roles ? { roles: pending.roles } : {}),
|
|
919
|
+
rolesKnown: pending.rolesKnown === true,
|
|
739
920
|
...(pending.label ? { label: pending.label } : {}),
|
|
740
921
|
});
|
|
741
922
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = require('../../package.json').name;
|
|
8
|
+
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
9
|
+
|
|
10
|
+
function parseVersion(value) {
|
|
11
|
+
const m = VERSION_RE.exec(String(value || '').trim());
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
return {
|
|
14
|
+
raw: m[0],
|
|
15
|
+
major: Number(m[1]),
|
|
16
|
+
minor: Number(m[2]),
|
|
17
|
+
patch: Number(m[3]),
|
|
18
|
+
prerelease: m[4] ? m[4].split('.') : [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function compareIdentifier(a, b) {
|
|
23
|
+
const an = /^[0-9]+$/.test(a);
|
|
24
|
+
const bn = /^[0-9]+$/.test(b);
|
|
25
|
+
if (an && bn) return Number(a) === Number(b) ? 0 : Number(a) > Number(b) ? 1 : -1;
|
|
26
|
+
if (an !== bn) return an ? -1 : 1;
|
|
27
|
+
return a === b ? 0 : a > b ? 1 : -1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compareVersions(a, b) {
|
|
31
|
+
const av = parseVersion(a);
|
|
32
|
+
const bv = parseVersion(b);
|
|
33
|
+
if (!av || !bv) throw new Error('versione semver non valida');
|
|
34
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
35
|
+
if (av[key] !== bv[key]) return av[key] > bv[key] ? 1 : -1;
|
|
36
|
+
}
|
|
37
|
+
if (!av.prerelease.length && !bv.prerelease.length) return 0;
|
|
38
|
+
if (!av.prerelease.length) return 1;
|
|
39
|
+
if (!bv.prerelease.length) return -1;
|
|
40
|
+
const n = Math.max(av.prerelease.length, bv.prerelease.length);
|
|
41
|
+
for (let i = 0; i < n; i += 1) {
|
|
42
|
+
if (av.prerelease[i] === undefined) return -1;
|
|
43
|
+
if (bv.prerelease[i] === undefined) return 1;
|
|
44
|
+
const c = compareIdentifier(av.prerelease[i], bv.prerelease[i]);
|
|
45
|
+
if (c) return c;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function registryVersion(stdout) {
|
|
51
|
+
const raw = String(stdout || '').trim();
|
|
52
|
+
let value = raw;
|
|
53
|
+
try {
|
|
54
|
+
const decoded = JSON.parse(raw);
|
|
55
|
+
if (typeof decoded === 'string') value = decoded;
|
|
56
|
+
} catch (_) { /* npm senza --json o output gia' plain */ }
|
|
57
|
+
const parsed = parseVersion(value);
|
|
58
|
+
if (!parsed) throw new Error('npm ha restituito una versione non valida');
|
|
59
|
+
return parsed.raw;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scrubError(error) {
|
|
63
|
+
return String((error && error.message) || error || 'errore sconosciuto')
|
|
64
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
65
|
+
.replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/gi, 'https://***@')
|
|
66
|
+
.replace(/\/(?:home|Users|data\/data\/com\.termux\/files\/home)\/[^\s:]+/g, '<local-path>')
|
|
67
|
+
.replace(/(?:Bearer\s+)?[A-Za-z0-9_-]{40,}/gi, '***')
|
|
68
|
+
.slice(0, 300);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pidAlive(pid) {
|
|
72
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
73
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readLock(file) {
|
|
77
|
+
try {
|
|
78
|
+
const st = fs.lstatSync(file);
|
|
79
|
+
if (!st.isFile() || st.isSymbolicLink()) return null;
|
|
80
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
81
|
+
return value && Number.isInteger(value.pid) && typeof value.token === 'string' ? value : null;
|
|
82
|
+
} catch (_) { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeLock(file, value, flags = 'wx') {
|
|
86
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
87
|
+
fs.writeFileSync(file, `${JSON.stringify(value)}\n`, { flag: flags, mode: 0o600 });
|
|
88
|
+
fs.chmodSync(file, 0o600);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function acquireUpdateLock(file, pid = process.pid, token = crypto.randomBytes(16).toString('hex')) {
|
|
92
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
93
|
+
try { writeLock(file, { pid, token, createdAt: Date.now() }); return { ok: true, token }; }
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (e.code !== 'EEXIST') throw e;
|
|
96
|
+
const current = readLock(file);
|
|
97
|
+
if (current && pidAlive(current.pid)) return { ok: false, current };
|
|
98
|
+
try { fs.unlinkSync(file); } catch (unlinkError) { if (unlinkError.code !== 'ENOENT') throw unlinkError; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, current: readLock(file) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function adoptUpdateLock(file, token, pid = process.pid) {
|
|
105
|
+
const current = readLock(file);
|
|
106
|
+
if (!current || current.token !== token) return false;
|
|
107
|
+
const tmp = `${file}.${pid}.tmp`;
|
|
108
|
+
try {
|
|
109
|
+
writeLock(tmp, { ...current, pid, adoptedAt: Date.now() });
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function releaseUpdateLock(file, token) {
|
|
119
|
+
const current = readLock(file);
|
|
120
|
+
if (!current || current.token !== token) return false;
|
|
121
|
+
try { fs.unlinkSync(file); return true; } catch (_) { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readState(file) {
|
|
125
|
+
try {
|
|
126
|
+
const st = fs.lstatSync(file);
|
|
127
|
+
if (!st.isFile() || st.isSymbolicLink()) return {};
|
|
128
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
129
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
130
|
+
} catch (_) { return {}; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeState(file, value) {
|
|
134
|
+
try {
|
|
135
|
+
const st = fs.lstatSync(file);
|
|
136
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error('update state target non sicuro');
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e.code !== 'ENOENT') throw e;
|
|
139
|
+
}
|
|
140
|
+
const dir = path.dirname(file);
|
|
141
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
142
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
143
|
+
try {
|
|
144
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
145
|
+
fs.chmodSync(tmp, 0o600);
|
|
146
|
+
fs.renameSync(tmp, file);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
PACKAGE_NAME, VERSION_RE, parseVersion, compareVersions, registryVersion,
|
|
155
|
+
scrubError, pidAlive, readLock, acquireUpdateLock, adoptUpdateLock, releaseUpdateLock,
|
|
156
|
+
readState, writeState,
|
|
157
|
+
};
|