@mmmbuto/nexuscrew 0.9.29 → 0.9.31
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/CHANGELOG.md +20 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/frontend/dist/assets/index-DaAL7P-m.js +167 -0
- package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +59 -12
- package/lib/cli/doctor.js +36 -7
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +27 -27
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +6 -6
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/decks/routes.js +11 -1
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +28 -19
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +21 -13
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +46 -36
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +26 -26
- package/lib/live-host/bridge.js +36 -36
- package/lib/live-host/routes.js +2 -2
- package/lib/live-host/store.js +4 -4
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +74 -3
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +372 -39
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/frontend/dist/assets/index-B_THfiL5.js +0 -167
package/lib/nodes/commands.js
CHANGED
|
@@ -19,6 +19,7 @@ const tunnel = require('./tunnel.js');
|
|
|
19
19
|
const reversePool = require('./reverse-pool.js');
|
|
20
20
|
const topologyCache = require('./topology-cache.js');
|
|
21
21
|
const inventory = require('./inventory.js');
|
|
22
|
+
const accessPresets = require('./access-presets.js');
|
|
22
23
|
const federation = require('../proxy/federation.js');
|
|
23
24
|
const { resolvePaths, DEFAULT_PORT } = require('../cli/url.js');
|
|
24
25
|
|
|
@@ -248,6 +249,63 @@ function nodesList(opts) {
|
|
|
248
249
|
return { code: 0, nodes, peers: visiblePeers };
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
// --- Event feed observability -------------------------------------------------
|
|
253
|
+
// Best-effort LOCAL read of the running server's feed diagnostics. The CLI
|
|
254
|
+
// never reads runtime files for this: if the local server is unreachable it
|
|
255
|
+
// says so explicitly instead of reporting zero deliveries.
|
|
256
|
+
// One line for the OS alert relay of that peer: same local diagnostics payload,
|
|
257
|
+
// counters and last reason only. A peer nobody tried to alert stays honest with
|
|
258
|
+
// zeros instead of disappearing.
|
|
259
|
+
function relayLineFor(body, peerNodeId) {
|
|
260
|
+
const section = (body && body.pushRelay) || {};
|
|
261
|
+
const own = (section.byOwner && section.byOwner[peerNodeId]) || {};
|
|
262
|
+
const last = own.lastReason ? ` · ultimo motivo ${own.lastReason}` : '';
|
|
263
|
+
return `relay: avvisi OS in coda ${own.queued || 0} · inviati ${own.pushed || 0} · scartati ${own.dropped || 0} · errori ${own.sendFailed || 0}${last}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function feedLinesFor(diag, peerNodeId) {
|
|
267
|
+
if (!diag || diag.ok !== true) {
|
|
268
|
+
return [`feed: diagnostica non disponibile (${(diag && diag.reason) || 'sconosciuto'}) — il server locale non risponde`];
|
|
269
|
+
}
|
|
270
|
+
const entry = (diag.body && Array.isArray(diag.body.peers) ? diag.body.peers : [])
|
|
271
|
+
.find((p) => p.nodeId === peerNodeId);
|
|
272
|
+
if (!entry) return ['feed: nessuna vista per questo peer'];
|
|
273
|
+
const enabled = diag.body.enabled === true;
|
|
274
|
+
const counters = (entry.feed && entry.feed.counters) || {};
|
|
275
|
+
const lastProblem = (entry.feed && (entry.feed.lastDropReason || entry.feed.lastRejectReason)) || 'none';
|
|
276
|
+
const grants = entry.grants || {};
|
|
277
|
+
return [
|
|
278
|
+
// The kill-switch state is its OWN line: it is the operator's first
|
|
279
|
+
// question when the feed is silent.
|
|
280
|
+
`feed: kill-switch ${enabled ? 'attivo (feed abilitato)' : 'OFF: feed disabilitato'} · revision ${entry.accessRevision} · label ${entry.accessLabel}`,
|
|
281
|
+
`feed: grant events=${grants.eventsAccess === true ? 'on' : 'off'} node-events=${grants.nodeEventsAccess === true ? 'on' : 'off'} · scope celle ${grants.cellVisibility || 'none'}${Array.isArray(grants.cells) && grants.cells.length ? ` (${grants.cells.join(', ')})` : ''}`,
|
|
282
|
+
`feed: cursore ${(entry.feed && entry.feed.lastCursor) || '-'} · stream ${(entry.feed && entry.feed.streams) || 0} · ultimo mancato: ${lastProblem}`,
|
|
283
|
+
`feed: contatori delivered ${(counters.delivered || 0)} · dropped ${(counters.dropped || 0)} · replayed ${(counters.replayed || 0)} · rejected ${(counters.rejected || 0)}`,
|
|
284
|
+
relayLineFor(diag.body, peerNodeId),
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function fetchFeedDiagnostics(opts = {}) {
|
|
289
|
+
let port = Number(opts.localAppPort) || null;
|
|
290
|
+
if (!port && opts.configPath) {
|
|
291
|
+
try { port = Number(JSON.parse(fs.readFileSync(opts.configPath, 'utf8')).port) || null; } catch (_) { port = null; }
|
|
292
|
+
}
|
|
293
|
+
let token = '';
|
|
294
|
+
try { token = fs.readFileSync(opts.tokenPath, 'utf8').trim(); } catch (_) { return { ok: false, reason: 'no-token' }; }
|
|
295
|
+
if (!port) return { ok: false, reason: 'no-port' };
|
|
296
|
+
try {
|
|
297
|
+
// The local diagnostics live under the authenticated API mount: without
|
|
298
|
+
// the /api prefix the CLI would read the static handler and report the
|
|
299
|
+
// server as unreachable while it is answering fine.
|
|
300
|
+
const r = await (opts.fetchImpl || fetch)(`http://127.0.0.1:${port}/api/diagnostics/events`, {
|
|
301
|
+
headers: { authorization: `Bearer ${token}` },
|
|
302
|
+
signal: AbortSignal.timeout(1500),
|
|
303
|
+
});
|
|
304
|
+
if (!r.ok) return { ok: false, reason: `http-${r.status}` };
|
|
305
|
+
return { ok: true, body: await r.json() };
|
|
306
|
+
} catch (_) { return { ok: false, reason: 'unreachable' }; }
|
|
307
|
+
}
|
|
308
|
+
|
|
251
309
|
function nodesInspect(opts) {
|
|
252
310
|
const log = opts.log || console.log;
|
|
253
311
|
const out = loadPeerInventory(opts);
|
|
@@ -273,11 +331,46 @@ function nodesInspect(opts) {
|
|
|
273
331
|
const cells = Array.isArray(peer.cells) ? peer.cells : [];
|
|
274
332
|
log(`celle: ${cells.length ? cells.join(', ') : 'nessuna (elenco vuoto)'} (scope: selected)`);
|
|
275
333
|
}
|
|
334
|
+
// Effective federated access: the label is DERIVED from the grants, and a
|
|
335
|
+
// record whose grants were never set says so instead of showing a preset
|
|
336
|
+
// inferred from defaults.
|
|
337
|
+
if (peer.kind === 'direct') {
|
|
338
|
+
const view = accessPresets.grantsOf(peer);
|
|
339
|
+
log(`accesso: ${view.label}`);
|
|
340
|
+
log(`grant: ${ACCESS_GRANT_KEYS.map((key) => `${key}=${view.grants[key] ? 'on' : 'off'}`).join(' ')}`);
|
|
341
|
+
log(`scope: celle ${view.grants.cellVisibility}`);
|
|
342
|
+
for (const line of feedLinesFor(opts.feedDiagnostics, peer.nodeId)) log(line);
|
|
343
|
+
}
|
|
276
344
|
log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
|
|
277
345
|
}
|
|
278
346
|
return { code: 0, peer: found.peer };
|
|
279
347
|
}
|
|
280
348
|
|
|
349
|
+
// --- Federated access grants -------------------------------------------------
|
|
350
|
+
// The grant vector of a peer is set on the node that OWNS the resources, in one
|
|
351
|
+
// validated write. The label (admin/user/nexushost/custom) is derived from the
|
|
352
|
+
// grants; nothing here stores or reads a role.
|
|
353
|
+
const ACCESS_GRANT_KEYS = ['eventsAccess', 'nodeEventsAccess', 'askReplyAccess', 'filesReadAccess', 'peerOperatorAccess'];
|
|
354
|
+
const ACCESS_EDIT_KEYS = new Set(['accessRole', ...ACCESS_GRANT_KEYS]);
|
|
355
|
+
|
|
356
|
+
const ACCESS_VECTOR_FIELDS = ['cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'];
|
|
357
|
+
|
|
358
|
+
// The prospective vector of a peer after applying `overrides`. It covers every
|
|
359
|
+
// field the invariants talk about, so a granular edit cannot leave the record
|
|
360
|
+
// in a state the preset validator would refuse (live hosting without the
|
|
361
|
+
// complete admin set being the case that matters).
|
|
362
|
+
function accessVectorFrom(peer, overrides) {
|
|
363
|
+
const visibility = Object.hasOwn(overrides, 'cellVisibility') ? overrides.cellVisibility : peer.cellVisibility;
|
|
364
|
+
const vector = { cellVisibility: visibility };
|
|
365
|
+
const cells = Object.hasOwn(overrides, 'cells') ? overrides.cells : peer.cells;
|
|
366
|
+
if (visibility === 'selected') vector.cells = Array.isArray(cells) ? cells : [];
|
|
367
|
+
const all = [...ACCESS_GRANT_KEYS, 'panelAccess', 'liveHostAccess'];
|
|
368
|
+
for (const key of all) {
|
|
369
|
+
vector[key] = Object.hasOwn(overrides, key) ? overrides[key] === true : peer[key] === true;
|
|
370
|
+
}
|
|
371
|
+
return vector;
|
|
372
|
+
}
|
|
373
|
+
|
|
281
374
|
function nodesEdit(opts) {
|
|
282
375
|
const log = opts.log || console.log;
|
|
283
376
|
if (isReadonly(opts)) { log('nodes edit: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
@@ -295,9 +388,12 @@ function nodesEdit(opts) {
|
|
|
295
388
|
// ai PANNELLI di questo nodo, non chi ha aperto il tunnel — quindi vale in
|
|
296
389
|
// entrambe le direzioni. `liveHostAccess` e' identica: riguarda chi puo'
|
|
297
390
|
// designare la cella ospite Live di QUESTO nodo, non chi ha aperto il tunnel.
|
|
391
|
+
// eventsReceive is the node's OWN choice to import a peer's feed: it is a
|
|
392
|
+
// local client decision in BOTH directions and never part of the owner's
|
|
393
|
+
// grant presets.
|
|
298
394
|
const allowed = node.direction === 'inbound'
|
|
299
|
-
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'])
|
|
300
|
-
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess']);
|
|
395
|
+
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess', 'accessRole', 'eventsReceive', ...ACCESS_GRANT_KEYS])
|
|
396
|
+
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess', 'accessRole', 'eventsReceive', ...ACCESS_GRANT_KEYS]);
|
|
301
397
|
const keys = Object.keys(supplied);
|
|
302
398
|
const invalid = keys.find((key) => !allowed.has(key));
|
|
303
399
|
if (invalid) {
|
|
@@ -308,6 +404,10 @@ function nodesEdit(opts) {
|
|
|
308
404
|
log('nodes edit: panelAccess vuole un booleano');
|
|
309
405
|
return { code: 1, reason: 'invalid-panel-access' };
|
|
310
406
|
}
|
|
407
|
+
if (Object.hasOwn(supplied, 'eventsReceive') && typeof supplied.eventsReceive !== 'boolean') {
|
|
408
|
+
log('nodes edit: eventsReceive vuole un booleano');
|
|
409
|
+
return { code: 1, reason: 'invalid-events-receive' };
|
|
410
|
+
}
|
|
311
411
|
if (Object.hasOwn(supplied, 'liveHostAccess') && typeof supplied.liveHostAccess !== 'boolean') {
|
|
312
412
|
log('nodes edit: liveHostAccess vuole un booleano');
|
|
313
413
|
return { code: 1, reason: 'invalid-live-host-access' };
|
|
@@ -342,9 +442,73 @@ function nodesEdit(opts) {
|
|
|
342
442
|
// ricostruisce il nodo da zero.
|
|
343
443
|
if (Object.hasOwn(patch, 'cellVisibility') && patch.cellVisibility !== 'selected') patch.cells = undefined;
|
|
344
444
|
|
|
445
|
+
const accessRequested = keys.some((key) => ACCESS_EDIT_KEYS.has(key));
|
|
446
|
+
const touchesVector = keys.some((key) => ACCESS_VECTOR_FIELDS.includes(key));
|
|
447
|
+
// A peer whose grants were never configured keeps its historical behaviour
|
|
448
|
+
// for panel/live flags; once a vector IS configured, every edit of that
|
|
449
|
+
// vector passes through the same validator and the same single write.
|
|
450
|
+
const enforceVector = accessRequested || (touchesVector && node.accessConfigured === true);
|
|
451
|
+
let accessVector = null;
|
|
452
|
+
if (enforceVector) {
|
|
453
|
+
const hasRole = Object.hasOwn(supplied, 'accessRole');
|
|
454
|
+
const granular = ACCESS_GRANT_KEYS.filter((key) => Object.hasOwn(supplied, key));
|
|
455
|
+
if (hasRole && granular.length > 0) {
|
|
456
|
+
log('nodes edit: --access-role e i grant granulari non si combinano nella stessa richiesta');
|
|
457
|
+
return { code: 1, reason: 'preset-and-grants' };
|
|
458
|
+
}
|
|
459
|
+
for (const key of granular) {
|
|
460
|
+
if (typeof supplied[key] !== 'boolean') {
|
|
461
|
+
log(`nodes edit: ${key} vuole un booleano`);
|
|
462
|
+
return { code: 1, reason: 'invalid-grant' };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (hasRole) {
|
|
466
|
+
if (typeof supplied.accessRole !== 'string' || !accessPresets.PRESET_NAMES.includes(supplied.accessRole)) {
|
|
467
|
+
log(`nodes edit: access-role non valido (atteso: ${accessPresets.PRESET_NAMES.join('|')})`);
|
|
468
|
+
return { code: 1, reason: 'invalid-access-role' };
|
|
469
|
+
}
|
|
470
|
+
accessVector = { ...accessPresets.PRESETS[supplied.accessRole] };
|
|
471
|
+
} else if (!accessRequested && touchesVector) {
|
|
472
|
+
// Panel/live/scope edit on a configured peer: same vector, same rules.
|
|
473
|
+
accessVector = accessVectorFrom(node, supplied);
|
|
474
|
+
} else {
|
|
475
|
+
// A granular edit only refines a vector that was set explicitly: a peer
|
|
476
|
+
// whose grants were never configured stays denied, and a partial edit
|
|
477
|
+
// must not promote it.
|
|
478
|
+
if (node.accessConfigured !== true) {
|
|
479
|
+
log('nodes edit: peer non configurato — applica prima un preset (--access-role)');
|
|
480
|
+
return { code: 1, reason: 'access-not-configured' };
|
|
481
|
+
}
|
|
482
|
+
accessVector = accessVectorFrom(node, supplied);
|
|
483
|
+
}
|
|
484
|
+
const check = accessPresets.validateGrants(accessVector);
|
|
485
|
+
if (!check.ok) {
|
|
486
|
+
log(`nodes edit: concessioni rifiutate — ${check.errors.join('; ')}`);
|
|
487
|
+
return { code: 1, reason: 'invalid-grants' };
|
|
488
|
+
}
|
|
489
|
+
for (const key of [...ACCESS_EDIT_KEYS, ...ACCESS_VECTOR_FIELDS]) delete patch[key];
|
|
490
|
+
}
|
|
491
|
+
|
|
345
492
|
let next;
|
|
346
|
-
|
|
347
|
-
|
|
493
|
+
if (enforceVector) {
|
|
494
|
+
// One validated read-modify-write against the policy revision: a concurrent
|
|
495
|
+
// edit loses the race and rereads instead of overwriting it.
|
|
496
|
+
const expected = Number.isInteger(opts.expectedRevision) ? opts.expectedRevision : store.accessRevisionOf(st);
|
|
497
|
+
let res;
|
|
498
|
+
try {
|
|
499
|
+
res = store.setPeerAccessGrantsCas({
|
|
500
|
+
filePath: nodesPath, name: node.name, grants: accessVector, expectedRevision: expected, extraPatch: patch,
|
|
501
|
+
});
|
|
502
|
+
} catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
|
|
503
|
+
if (!res.ok) {
|
|
504
|
+
log(`nodes edit: revision delle concessioni superata (attuale ${res.revision}) — rileggi e riprova`);
|
|
505
|
+
return { code: 1, reason: 'revision-conflict', revision: res.revision };
|
|
506
|
+
}
|
|
507
|
+
next = store.loadStoreStrict(nodesPath);
|
|
508
|
+
} else {
|
|
509
|
+
try { next = store.updateNode(st, node.name, patch); }
|
|
510
|
+
catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
|
|
511
|
+
}
|
|
348
512
|
|
|
349
513
|
const transportChanged = node.direction === 'outbound'
|
|
350
514
|
&& ['ssh', 'sshPort'].some((key) => Object.hasOwn(patch, key));
|
|
@@ -377,6 +541,33 @@ function nodesEdit(opts) {
|
|
|
377
541
|
return { code: 0, name: node.name, node: edited, restarted: wasUp };
|
|
378
542
|
}
|
|
379
543
|
|
|
544
|
+
// --- nodes access: read-only inventory --------------------------------------
|
|
545
|
+
// Shows what every peer may currently do on this node, with the revision of
|
|
546
|
+
// the policy. Peers whose grants were never configured are listed as such:
|
|
547
|
+
// they are the ones an explicit migration has to set, and nothing promotes
|
|
548
|
+
// them by name or by age of pairing.
|
|
549
|
+
function nodesAccess(opts = {}) {
|
|
550
|
+
const log = opts.log || console.log;
|
|
551
|
+
const { nodesPath } = resolveNodePaths(opts);
|
|
552
|
+
let st;
|
|
553
|
+
try { st = store.loadStoreStrict(nodesPath); }
|
|
554
|
+
catch (e) { log(`nodes access: ${e.message}`); return { code: 1, reason: 'store invalido', status: e.status || 500 }; }
|
|
555
|
+
const view = store.accessInventory(st);
|
|
556
|
+
const legacy = view.peers.filter((peer) => !peer.configured);
|
|
557
|
+
if (opts.json) {
|
|
558
|
+
log(JSON.stringify({ revision: view.revision, peers: view.peers, unconfigured: legacy.map((p) => p.name) }, null, 2));
|
|
559
|
+
return { code: 0, ...view, unconfigured: legacy.map((p) => p.name) };
|
|
560
|
+
}
|
|
561
|
+
log(`revision concessioni: ${view.revision}`);
|
|
562
|
+
if (view.peers.length === 0) log('nessun peer diretto');
|
|
563
|
+
for (const peer of view.peers) {
|
|
564
|
+
log(`${peer.name}${peer.nodeId ? ` (${peer.nodeId})` : ''}: ${peer.label}`);
|
|
565
|
+
log(` ${ACCESS_GRANT_KEYS.map((key) => `${key}=${peer.grants[key] ? 'on' : 'off'}`).join(' ')} · celle ${peer.grants.cellVisibility}`);
|
|
566
|
+
}
|
|
567
|
+
if (legacy.length > 0) log(`da configurare esplicitamente (${legacy.length}): ${legacy.map((p) => p.name).join(', ')}`);
|
|
568
|
+
return { code: 0, ...view, unconfigured: legacy.map((p) => p.name) };
|
|
569
|
+
}
|
|
570
|
+
|
|
380
571
|
// --- nodes remove ----------------------------------------------------------
|
|
381
572
|
function nodesRemove(opts) {
|
|
382
573
|
const log = opts.log || console.log;
|
|
@@ -683,6 +874,9 @@ function nodesSetToken(opts) {
|
|
|
683
874
|
}
|
|
684
875
|
|
|
685
876
|
module.exports = {
|
|
877
|
+
fetchFeedDiagnostics, feedLinesFor,
|
|
878
|
+
|
|
879
|
+
nodesAccess, ACCESS_GRANT_KEYS,
|
|
686
880
|
nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest, nodesTestAll,
|
|
687
881
|
nodesUp, nodesDown, nodesRestart, nodesSetToken,
|
|
688
882
|
// helper esposti per test/riuso
|
package/lib/nodes/store.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/nodes/store.js — nodes.json secret store
|
|
2
|
+
// lib/nodes/store.js — nodes.json secret store.
|
|
3
3
|
//
|
|
4
4
|
// nodes.json e' un SECRET STORE, non config qualunque: contiene i token dei nodi
|
|
5
|
-
//
|
|
5
|
+
// Remoti (iniettati dal proxy in). Percio' riusa lo stesso hardening di
|
|
6
6
|
// lib/fleet/definitions.js e del token file:
|
|
7
7
|
// - permessi 0600, scrittura atomica (tmp stessa dir + rename), rifiuto symlink
|
|
8
8
|
// - schema STRICT validato al load: garbage -> errore esplicito, MAI guess
|
|
@@ -19,6 +19,7 @@ const path = require('node:path');
|
|
|
19
19
|
const crypto = require('node:crypto');
|
|
20
20
|
const reversePool = require('./reverse-pool.js');
|
|
21
21
|
const identity = require('./identity.js');
|
|
22
|
+
const accessPresets = require('./access-presets.js');
|
|
22
23
|
|
|
23
24
|
const SCHEMA_VERSION = 3;
|
|
24
25
|
const PREVIOUS_SCHEMA_VERSION = 2;
|
|
@@ -28,14 +29,14 @@ const MAX_TOKEN_LEN = 4096;
|
|
|
28
29
|
const MAX_KEYPATH_LEN = 4096;
|
|
29
30
|
const MAX_SSH_LEN = 320; // user(<=32) + '@' + host(<=255)
|
|
30
31
|
|
|
31
|
-
// name:
|
|
32
|
-
//
|
|
32
|
+
// name: strict key also used as a path/route segment -> no '.', no '/',
|
|
33
|
+
// no uppercase. Aligned with the strict name contract (^[a-z0-9-]{1,32}$).
|
|
33
34
|
const NODE_NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
34
35
|
// nodeId: id stabile per-installazione, hex casuale (crypto.randomBytes(16)).
|
|
35
36
|
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
36
37
|
// user@host: entrambe le parti argv-safe (no spazi, no leading '-' -> mai
|
|
37
38
|
// interpretabile come opzione ssh). Host: hostname/FQDN/IPv4 (niente ':' -> IPv6
|
|
38
|
-
//
|
|
39
|
+
// Va usato via Host alias in ~/.ssh/config, fuori scope).
|
|
39
40
|
const SSH_USER_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
40
41
|
const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
|
|
41
42
|
|
|
@@ -112,6 +113,10 @@ const NODE_KEYS = new Set([
|
|
|
112
113
|
// Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
|
|
113
114
|
// che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
|
|
114
115
|
'cellVisibility', 'cells',
|
|
116
|
+
// eventsReceive: the CLIENT's own local choice to import an owner's feed.
|
|
117
|
+
// It is NOT a grant the owner sets, it never travels in a preset, and it
|
|
118
|
+
// only ever decides what THIS node pulls — never what a peer may read here.
|
|
119
|
+
'eventsReceive',
|
|
115
120
|
// Se questo peer puo' aprire i PANNELLI delle celle di questo nodo. Distinto
|
|
116
121
|
// da `cellVisibility`, che dice quali celle il peer VEDE: dietro un pannello
|
|
117
122
|
// c'e' un browser con sessioni gia' autenticate, e un accesso di quel tipo non
|
|
@@ -132,13 +137,25 @@ const NODE_KEYS = new Set([
|
|
|
132
137
|
// grant potra' pretendere la prima. `keyConflict` conserva una chiave DIVERSA
|
|
133
138
|
// vista dopo il legame, senza sostituire quella legata.
|
|
134
139
|
'publicKey', 'keySource', 'keyBoundAt', 'keyConflict',
|
|
135
|
-
//
|
|
140
|
+
// Stessa-origin, meta' remota: la porta pannello del peer inoltrata sul
|
|
136
141
|
// NOSTRO loopback (panelLocalPort) e la sua origine remota (panelRemotePort).
|
|
137
142
|
// COPPIA obbligatoria: un -L ha due estremita', mezza coppia e' un forward
|
|
138
143
|
// impossibile, non "meno pannello". Campi assenti = peer accoppiato prima di
|
|
139
144
|
// questa negoziazione: via storica, nessun comportamento nuovo. Le porte non
|
|
140
145
|
// sono segreti (come localPort/remotePort, che stanno qui accanto).
|
|
141
146
|
'panelLocalPort', 'panelRemotePort',
|
|
147
|
+
// Federated access grants: what this peer may do with the resources this node
|
|
148
|
+
// OWNS (events, asks, files) beyond cell visibility. Same shape and same rule
|
|
149
|
+
// as panelAccess/liveHostAccess: default denied, one node at a time. A peer
|
|
150
|
+
// does not gain them by being paired, and the receiving client's own choice
|
|
151
|
+
// to receive events is NOT one of them (it is local to that client).
|
|
152
|
+
'eventsAccess', 'nodeEventsAccess', 'askReplyAccess', 'filesReadAccess', 'peerOperatorAccess',
|
|
153
|
+
// Whether the grant vector was set EXPLICITLY. Grants alone cannot tell a
|
|
154
|
+
// legacy record (all defaults denied) from a deliberate vector that happens
|
|
155
|
+
// to deny everything: without this marker a legacy peer would read as a
|
|
156
|
+
// coherent "custom" preset instead of "not configured". It is provenance,
|
|
157
|
+
// not a permission — it grants nothing and handlers never read it as a role.
|
|
158
|
+
'accessConfigured',
|
|
142
159
|
]);
|
|
143
160
|
|
|
144
161
|
// Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
|
|
@@ -248,6 +265,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
248
265
|
const roles = parseRoles(n.roles);
|
|
249
266
|
if (!roles) return null;
|
|
250
267
|
if (n.rolesKnown !== undefined && typeof n.rolesKnown !== 'boolean') return null;
|
|
268
|
+
if (n.eventsReceive !== undefined && typeof n.eventsReceive !== 'boolean') return null;
|
|
251
269
|
// Before 0.8.9 `roles` was filled with a local default and did not describe
|
|
252
270
|
// the remote peer. Only the explicit marker makes role-based health safe.
|
|
253
271
|
const rolesKnown = n.rolesKnown === true;
|
|
@@ -285,6 +303,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
285
303
|
// Stessa regola, stesso motivo: un record senza il campo non acquisisce la
|
|
286
304
|
// designazione live per anzianita' del pairing.
|
|
287
305
|
liveHostAccess: n.liveHostAccess === undefined ? false : n.liveHostAccess,
|
|
306
|
+
// Le cinque concessioni federate seguono la stessa regola: assenti valgono
|
|
307
|
+
// NEGATE. Nessuna migrazione silenziosa al load: il vettore va impostato da
|
|
308
|
+
// un atto esplicito (preset o edit granulare), altrimenti resta negato.
|
|
309
|
+
eventsReceive: n.eventsReceive === undefined ? false : n.eventsReceive === true,
|
|
310
|
+
eventsAccess: n.eventsAccess === undefined ? false : n.eventsAccess,
|
|
311
|
+
nodeEventsAccess: n.nodeEventsAccess === undefined ? false : n.nodeEventsAccess,
|
|
312
|
+
askReplyAccess: n.askReplyAccess === undefined ? false : n.askReplyAccess,
|
|
313
|
+
filesReadAccess: n.filesReadAccess === undefined ? false : n.filesReadAccess,
|
|
314
|
+
peerOperatorAccess: n.peerOperatorAccess === undefined ? false : n.peerOperatorAccess,
|
|
315
|
+
accessConfigured: n.accessConfigured === true,
|
|
288
316
|
visibility: n.visibility || 'network',
|
|
289
317
|
};
|
|
290
318
|
if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
|
|
@@ -294,6 +322,12 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
294
322
|
if (typeof out.shared !== 'boolean') return null;
|
|
295
323
|
if (typeof out.panelAccess !== 'boolean') return null;
|
|
296
324
|
if (typeof out.liveHostAccess !== 'boolean') return null;
|
|
325
|
+
if (typeof out.eventsAccess !== 'boolean') return null;
|
|
326
|
+
if (typeof out.nodeEventsAccess !== 'boolean') return null;
|
|
327
|
+
if (typeof out.askReplyAccess !== 'boolean') return null;
|
|
328
|
+
if (typeof out.filesReadAccess !== 'boolean') return null;
|
|
329
|
+
if (typeof out.peerOperatorAccess !== 'boolean') return null;
|
|
330
|
+
if (typeof out.accessConfigured !== 'boolean') return null;
|
|
297
331
|
if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
|
|
298
332
|
if (identityFile) out.identityFile = identityFile;
|
|
299
333
|
// Keep the old public field while reading v1 so old callers/tests and a
|
|
@@ -303,7 +337,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
303
337
|
if (!isPort(n.reversePort)) return null;
|
|
304
338
|
out.reversePort = n.reversePort;
|
|
305
339
|
}
|
|
306
|
-
// Coppia pannello (
|
|
340
|
+
// Coppia pannello (stessa-origin remota): o entrambe le porte o nessuna.
|
|
307
341
|
// Una sola meta' non viene indovinata — sarebbe un -L monco. E la porta
|
|
308
342
|
// locale del pannello non puo' stare su quella del control plane: ssh
|
|
309
343
|
// cadrebbe con ExitOnForwardFailure portandosi via il tunnel intero.
|
|
@@ -427,7 +461,7 @@ function parseStore(raw) {
|
|
|
427
461
|
|
|
428
462
|
if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
|
|
429
463
|
const rootKeys = d.schemaVersion === SCHEMA_VERSION
|
|
430
|
-
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized'])
|
|
464
|
+
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision'])
|
|
431
465
|
: new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous']);
|
|
432
466
|
if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
|
|
433
467
|
if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
|
|
@@ -461,6 +495,16 @@ function parseStore(raw) {
|
|
|
461
495
|
|
|
462
496
|
const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
|
|
463
497
|
|
|
498
|
+
// Revision of the peer access policy. Absent means 0: a store written
|
|
499
|
+
// before this field existed is at revision 0 and the first explicit write
|
|
500
|
+
// advances it to 1. It is kept out of the parsed object when absent so a
|
|
501
|
+
// plain load never rewrites the file with a field nobody set.
|
|
502
|
+
if (d.accessRevision !== undefined) {
|
|
503
|
+
if (d.schemaVersion !== SCHEMA_VERSION) return null;
|
|
504
|
+
if (!Number.isInteger(d.accessRevision) || d.accessRevision < 0) return null;
|
|
505
|
+
out.accessRevision = d.accessRevision;
|
|
506
|
+
}
|
|
507
|
+
|
|
464
508
|
if (d.rendezvous !== undefined && d.rendezvous !== null) {
|
|
465
509
|
const rdv = parseRendezvous(d.rendezvous);
|
|
466
510
|
if (!rdv) return null;
|
|
@@ -647,6 +691,101 @@ function updateNode(store, name, patch) {
|
|
|
647
691
|
return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
|
|
648
692
|
}
|
|
649
693
|
|
|
694
|
+
// --- Concessioni federate: preset, CAS, inventario -------------------------
|
|
695
|
+
|
|
696
|
+
function accessRevisionOf(store) {
|
|
697
|
+
return store && Number.isInteger(store.accessRevision) && store.accessRevision >= 0
|
|
698
|
+
? store.accessRevision : 0;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Pure: returns a new store with the peer's grant vector replaced by a
|
|
702
|
+
// validated one. The vector is validated as a whole, so an edit that keeps
|
|
703
|
+
// live hosting while dropping another admin grant is refused here too.
|
|
704
|
+
function setPeerAccessGrants(store, name, grants) {
|
|
705
|
+
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
706
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
707
|
+
const check = accessPresets.validateGrants(grants);
|
|
708
|
+
if (!check.ok) throw new Error(`concessioni non valide: ${check.errors.join('; ')}`);
|
|
709
|
+
const next = { ...store.nodes[idx] };
|
|
710
|
+
next.cellVisibility = grants.cellVisibility;
|
|
711
|
+
if (grants.cellVisibility === 'selected') next.cells = [...(grants.cells || [])];
|
|
712
|
+
else delete next.cells;
|
|
713
|
+
for (const key of accessPresets.BOOLEAN_GRANTS) next[key] = grants[key] === true;
|
|
714
|
+
next.accessConfigured = true;
|
|
715
|
+
const nodes = store.nodes.slice();
|
|
716
|
+
nodes[idx] = next;
|
|
717
|
+
const out = { ...store, schemaVersion: SCHEMA_VERSION, nodes };
|
|
718
|
+
out.accessRevision = accessRevisionOf(store) + 1;
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Pure: applies a named preset to one peer.
|
|
723
|
+
function setPeerAccessPreset(store, name, presetName) {
|
|
724
|
+
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
725
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
726
|
+
const applied = accessPresets.applyPreset(store.nodes[idx], presetName);
|
|
727
|
+
const vector = { cellVisibility: applied.cellVisibility };
|
|
728
|
+
if (applied.cells !== undefined) vector.cells = applied.cells;
|
|
729
|
+
for (const key of accessPresets.BOOLEAN_GRANTS) vector[key] = applied[key];
|
|
730
|
+
return setPeerAccessGrants(store, name, vector);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Read-only inventory: what every peer is allowed to do on THIS node, with the
|
|
734
|
+
// label derived from the grants. Used by `nodes show`, by the settings view and
|
|
735
|
+
// by the explicit migration: a legacy peer shows up as not configured, never as
|
|
736
|
+
// a preset inferred from defaults.
|
|
737
|
+
function accessInventory(store) {
|
|
738
|
+
const peers = (store && store.nodes ? store.nodes : []).map((peer) => {
|
|
739
|
+
const view = accessPresets.grantsOf(peer);
|
|
740
|
+
return {
|
|
741
|
+
name: peer.name,
|
|
742
|
+
nodeId: peer.nodeId || null,
|
|
743
|
+
direction: peer.direction,
|
|
744
|
+
configured: view.configured,
|
|
745
|
+
label: view.label,
|
|
746
|
+
grants: view.grants,
|
|
747
|
+
};
|
|
748
|
+
});
|
|
749
|
+
return { revision: accessRevisionOf(store), peers };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Compare-and-set write for the peer access policy: one validated read-modify-
|
|
753
|
+
// write over the store file. `expectedRevision` must be an integer that matches
|
|
754
|
+
// the current revision, otherwise the write is refused and the caller rereads
|
|
755
|
+
// and retries — never a permissive default. The whole path is synchronous, so
|
|
756
|
+
// two callers in this process cannot interleave between the read and the write.
|
|
757
|
+
function setPeerAccessGrantsCas({ filePath, name, grants, expectedRevision, extraPatch }) {
|
|
758
|
+
const current = loadStoreStrict(filePath);
|
|
759
|
+
if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
|
|
760
|
+
return { ok: false, conflict: true, revision: accessRevisionOf(current) };
|
|
761
|
+
}
|
|
762
|
+
let next = current;
|
|
763
|
+
if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
|
|
764
|
+
next = setPeerAccessGrants(next, name, grants);
|
|
765
|
+
const written = atomicWriteStore(filePath, next);
|
|
766
|
+
return {
|
|
767
|
+
ok: true,
|
|
768
|
+
name,
|
|
769
|
+
revision: accessRevisionOf(written),
|
|
770
|
+
grants: accessPresets.grantsOf(written.nodes.find((n) => n.name === name)).grants,
|
|
771
|
+
label: accessPresets.grantsOf(written.nodes.find((n) => n.name === name)).label,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Same, by preset name.
|
|
776
|
+
function setPeerAccessPresetCas({ filePath, name, presetName, expectedRevision, extraPatch }) {
|
|
777
|
+
const current = loadStoreStrict(filePath);
|
|
778
|
+
if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
|
|
779
|
+
return { ok: false, conflict: true, revision: accessRevisionOf(current) };
|
|
780
|
+
}
|
|
781
|
+
let next = current;
|
|
782
|
+
if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
|
|
783
|
+
next = setPeerAccessPreset(next, name, presetName);
|
|
784
|
+
const written = atomicWriteStore(filePath, next);
|
|
785
|
+
const view = accessPresets.grantsOf(written.nodes.find((n) => n.name === name));
|
|
786
|
+
return { ok: true, name, revision: accessRevisionOf(written), grants: view.grants, label: view.label };
|
|
787
|
+
}
|
|
788
|
+
|
|
650
789
|
// The upgrade is explicit: normal legacy pairing remains schema v2 and keeps
|
|
651
790
|
// working with a previous NexusCrew binary. Enabling rotation is the only
|
|
652
791
|
// path that writes the v3 anchor and therefore requires both peers to support
|
|
@@ -691,6 +830,7 @@ function redactNode(n) {
|
|
|
691
830
|
rolesKnown: n.rolesKnown === true,
|
|
692
831
|
transport: n.transport || 'ssh',
|
|
693
832
|
autostart: !!n.autostart,
|
|
833
|
+
eventsReceive: n.eventsReceive === true,
|
|
694
834
|
shared: n.shared === true,
|
|
695
835
|
panelAccess: n.panelAccess === true,
|
|
696
836
|
liveHostAccess: n.liveHostAccess === true,
|
|
@@ -758,7 +898,7 @@ function hasPairedPeers(store) {
|
|
|
758
898
|
}
|
|
759
899
|
|
|
760
900
|
// --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
|
|
761
|
-
// Se config.json contiene un array `nodes` legacy (vecchio formato pre-
|
|
901
|
+
// Se config.json contiene un array `nodes` legacy (vecchio formato pre-),
|
|
762
902
|
// lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
|
|
763
903
|
// nodes.json ha gia' dei nodi (mai overwrite). Strict: un nodo legacy malformato
|
|
764
904
|
// -> throw esplicito (non importa silenziosamente spazzatura).
|
|
@@ -885,6 +1025,9 @@ module.exports = {
|
|
|
885
1025
|
defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
886
1026
|
// mutazioni
|
|
887
1027
|
getNode, addNode, removeNode, setNodeToken, updateNode, upgradeToReversePoolSchema, setNodeReversePool,
|
|
1028
|
+
// concessioni federate (preset, CAS, inventario)
|
|
1029
|
+
accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory,
|
|
1030
|
+
setPeerAccessGrantsCas, setPeerAccessPresetCas,
|
|
888
1031
|
// redazione
|
|
889
1032
|
redactNode, redactStore, hasPairedPeers,
|
|
890
1033
|
// migrazione
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/nodes/tunnel.js — SSH tunnel manager
|
|
2
|
+
// lib/nodes/tunnel.js — SSH tunnel manager.
|
|
3
3
|
//
|
|
4
4
|
// I processi ssh sono gestiti da un solo supervisor detached, avviato dal
|
|
5
5
|
// lifecycle del servizio o dalle azioni PWA autenticate. argv puro (spawn con
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// unit-testabili senza lanciare ssh.
|
|
11
11
|
// - lifecycle per-tunnel via pidfile (start/stop/restart/state): il processo
|
|
12
12
|
// ssh e' detached, la sua liveness/porta stabile e' interrogabile da
|
|
13
|
-
//
|
|
13
|
+
// `status --json` e (in) dal proxy. Porta STABILE da nodes.json; una
|
|
14
14
|
// collisione fa fallire ExitOnForwardFailure -> si segnala, NON si riciclano
|
|
15
15
|
// porte a caso.
|
|
16
16
|
const fs = require('node:fs');
|
|
@@ -21,7 +21,7 @@ const { spawn, spawnSync } = require('node:child_process');
|
|
|
21
21
|
const pidf = require('../cli/pidfile.js');
|
|
22
22
|
const store = require('./store.js');
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// SSH options shared by both templates (exact order and values).
|
|
25
25
|
const SSH_BASE_OPTS = Object.freeze([
|
|
26
26
|
'-N',
|
|
27
27
|
'-o', 'ExitOnForwardFailure=yes',
|
|
@@ -44,7 +44,7 @@ function assertForwardSpec(node) {
|
|
|
44
44
|
if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
|
|
45
45
|
if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
|
|
46
46
|
if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
|
|
47
|
-
// Coppia pannello (
|
|
47
|
+
// Coppia pannello (stessa-origin remota): o entrambe o nessuna, e mai
|
|
48
48
|
// sulla porta locale del control plane — il bind conflitto abbatterebbe il
|
|
49
49
|
// tunnel intero con ExitOnForwardFailure.
|
|
50
50
|
if (node.panelLocalPort !== undefined || node.panelRemotePort !== undefined) {
|
|
@@ -61,7 +61,7 @@ function assertForwardSpec(node) {
|
|
|
61
61
|
if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// Forward client->
|
|
64
|
+
// Forward client->node (client role):
|
|
65
65
|
// ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30
|
|
66
66
|
// -o ServerAliveCountMax=3 -o BatchMode=yes -i <key>
|
|
67
67
|
// -L 127.0.0.1:<locale>:127.0.0.1:<remota> user@host
|
|
@@ -108,7 +108,7 @@ function buildReverseArgs(node, { remotePort, targetPort } = {}) {
|
|
|
108
108
|
]);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
//
|
|
111
|
+
// Exponential backoff + jitter. Deterministic with an injected rng.
|
|
112
112
|
// delay = clamp(base * factor^attempt, 0..cap) * (1 +- jitter*(2*rand-1))
|
|
113
113
|
// rng()=0.5 -> jitter nullo (ritorna il valore base clamperato); test deterministici.
|
|
114
114
|
function backoffDelay(attempt, opts = {}) {
|
|
@@ -735,7 +735,7 @@ function authorizedKeysLine({ remotePort, panelRemotePort, pub } = {}) {
|
|
|
735
735
|
// registrato in revisione: `null | stringa` comprimeva stati DIVERSI, e il
|
|
736
736
|
// livello basso componeva frasi su fatti che non conosce — meta' della 0.9.5
|
|
737
737
|
// e' nata li'). Gli esiti sono DATI, senza testo per l'utente: le frasi si
|
|
738
|
-
//
|
|
738
|
+
// Compongono al confine UI/log, mai qui. Si aggancia a: una chiave cifrata
|
|
739
739
|
// dava `null`, indistinguibile da «file illeggibile», e il pairing ometteva
|
|
740
740
|
// la riga in silenzio.
|
|
741
741
|
//
|