@mmmbuto/nexuscrew 0.8.38 → 0.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mcp/server.js CHANGED
@@ -20,10 +20,12 @@
20
20
  const readline = require('node:readline');
21
21
  const crypto = require('node:crypto');
22
22
  const os = require('node:os');
23
+ const path = require('node:path');
23
24
  const { execFile } = require('node:child_process');
24
25
  const { loadConfig } = require('../config.js');
25
26
  const { readTokenSafe } = require('../auth/token.js');
26
27
  const { isValidSession } = require('../files/store.js');
28
+ const { loadOrCreateBridgeSecret, signedHeaders } = require('../audio/bridge-auth.js');
27
29
  const VERSION = require('../../package.json').version;
28
30
  const MCP_COMPANIONS = require('../../mcp-companions.json');
29
31
  const { TOOLS, IDENTITY_CODE, IDENTITY_REMEDIATION } = require('./tools.js');
@@ -162,17 +164,39 @@ function createMcpServer(opts = {}) {
162
164
  throw new Error('token NexusCrew non leggibile: il server e\' inizializzato? (nexuscrew init)');
163
165
  }
164
166
 
165
- async function api(method, apiPath, body) {
167
+ // Segreto del bridge: file 0600 accanto al token, distinto dal token stesso.
168
+ // Serve dove il Bearer non basta — Audio Share — perche' il Bearer prova solo
169
+ // "qualcuno in loopback ce l'ha", non "questa e' la cella X".
170
+ const bridgeKeyPath = () => cfg.audioBridgeSecretPath || path.join(path.dirname(cfg.tokenPath), 'audio-bridge.key');
171
+
172
+ // `opts.signedSession` attiva la firma HMAC del bridge: copre metodo, path,
173
+ // sessione, timestamp, nonce e i BYTE del body effettivamente inviati. Per
174
+ // questo il payload viene serializzato UNA volta sola e riusato: firmare un
175
+ // JSON e spedirne un altro, anche solo con un ordine di chiavi diverso,
176
+ // produrrebbe una firma valida per un corpo che il server non vede mai.
177
+ async function api(method, apiPath, body, opts = {}) {
166
178
  const token = readToken();
179
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
180
+ let signed = {};
181
+ if (opts.signedSession) {
182
+ try {
183
+ signed = signedHeaders(loadOrCreateBridgeSecret(bridgeKeyPath()), {
184
+ method, path: apiPath, session: opts.signedSession, rawBody: payload === undefined ? '' : payload,
185
+ });
186
+ } catch (_) {
187
+ throw new Error('segreto bridge audio non leggibile: il server e\' inizializzato? (nexuscrew init)');
188
+ }
189
+ }
167
190
  let r;
168
191
  try {
169
192
  r = await fetchImpl(`${baseUrl}${apiPath}`, {
170
193
  method,
171
194
  headers: {
172
195
  authorization: `Bearer ${token}`,
173
- ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
196
+ ...signed,
197
+ ...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
174
198
  },
175
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
199
+ ...(payload !== undefined ? { body: payload } : {}),
176
200
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
177
201
  });
178
202
  } catch (e) {
package/lib/mcp/tools.js CHANGED
@@ -496,6 +496,148 @@ const TOOLS = [
496
496
  return out;
497
497
  },
498
498
  },
499
+ {
500
+ name: 'nc_speak',
501
+ description: 'Speak un utterance audio verso un nodo target (exact instanceId). Ritorna un receipt caller-scoped con status per-endpoint (refused|unreachable|accepted|spoken|unknown) e attribution; MAI text/lang/voice o aggregate success. L\'origin genera un utteranceId immutabile se omesso.',
502
+ inputSchema: {
503
+ type: 'object',
504
+ properties: {
505
+ target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
506
+ text: { type: 'string', description: 'utterance text (bounded)' },
507
+ lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
508
+ voice: { type: 'string', description: 'voice id hint (optional)' },
509
+ urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
510
+ utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
511
+ },
512
+ required: ['target', 'text'],
513
+ },
514
+ async handler(args, ctx) {
515
+ const target = argString(args, 'target', { required: true, max: 32 });
516
+ const text = argString(args, 'text', { required: true, max: 320 });
517
+ const lang = argString(args, 'lang', { max: 35 });
518
+ const voice = argString(args, 'voice', { max: 64 });
519
+ if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
520
+ throw new Error('urgency deve essere "normal" o "high"');
521
+ }
522
+ const urgency = args.urgency === 'high' ? 'high' : 'normal';
523
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
524
+ const identity = await ctx.identity();
525
+ const session = requireSession(identity.session, 'nc_speak', identity.code);
526
+ // La sessione NON viaggia nel body: sarebbe una dichiarazione, e una
527
+ // dichiarazione e' falsificabile da chiunque abbia il token della UI.
528
+ // Viaggia dentro la firma HMAC del bridge, che il server verifica prima di
529
+ // attribuire l'enunciato a una cella.
530
+ const payload = { target, text, urgency, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}) };
531
+ const receipt = await ctx.api('POST', '/api/audio/speak', payload, { signedSession: session });
532
+ return { receipt };
533
+ },
534
+ },
535
+ {
536
+ name: 'nc_speak_status',
537
+ description: 'Interroga lo status di uno speak per utteranceId (read-only). Ritorna il receipt caller-scoped redacted (status/reason/attribution); MAI text/lang/voice/secret.',
538
+ inputSchema: {
539
+ type: 'object',
540
+ properties: { utteranceId: { type: 'string', description: 'utterance id da interrogare' } },
541
+ required: ['utteranceId'],
542
+ },
543
+ annotations: { readOnlyHint: true },
544
+ async handler(args, ctx) {
545
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
546
+ const identity = await ctx.identity();
547
+ const session = requireSession(identity.session, 'nc_speak_status', identity.code);
548
+ const receipt = await ctx.api('GET', `/api/audio/speak/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
549
+ return { receipt };
550
+ },
551
+ },
552
+ {
553
+ name: 'nc_speak_stop',
554
+ description: 'Ferma un enunciato su un nodo target. Con utteranceId ferma quello specifico (solo se e\' del chiamante), senza utteranceId ferma tutto sul target. Lo stop locale del nodo resta sovrano e non dipende da questa chiamata.',
555
+ inputSchema: {
556
+ type: 'object',
557
+ properties: {
558
+ target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
559
+ utteranceId: { type: 'string', description: 'enunciato da fermare; omesso = tutti gli enunciati del target' },
560
+ },
561
+ required: ['target'],
562
+ },
563
+ async handler(args, ctx) {
564
+ const target = argString(args, 'target', { required: true, max: 32 });
565
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
566
+ const identity = await ctx.identity();
567
+ const session = requireSession(identity.session, 'nc_speak_stop', identity.code);
568
+ const receipt = await ctx.api('POST', '/api/audio/stop', {
569
+ target, ...(utteranceId ? { utteranceId } : {}),
570
+ }, { signedSession: session });
571
+ return { receipt };
572
+ },
573
+ },
574
+ {
575
+ name: 'nc_speak_group',
576
+ description: 'Speak verso un gruppo audio locale nominato. Il gruppo espande solo instanceId esatti: primary-failover prova un endpoint alla volta, fanout e esplicito. Ritorna subito un receipt per endpoint; usa nc_speak_group_status per gli aggiornamenti. Un gruppo non concede consenso e non aggira liveness/ACL del target.',
577
+ inputSchema: {
578
+ type: 'object',
579
+ properties: {
580
+ group: { type: 'string', description: 'nome del gruppo audio locale (a-z, 0-9, trattini)' },
581
+ text: { type: 'string', description: 'utterance text (bounded)' },
582
+ lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
583
+ voice: { type: 'string', description: 'voice id hint (optional)' },
584
+ urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
585
+ utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
586
+ },
587
+ required: ['group', 'text'],
588
+ },
589
+ async handler(args, ctx) {
590
+ const group = argString(args, 'group', { required: true, max: 32 });
591
+ const text = argString(args, 'text', { required: true, max: 320 });
592
+ const lang = argString(args, 'lang', { max: 35 });
593
+ const voice = argString(args, 'voice', { max: 64 });
594
+ if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
595
+ throw new Error('urgency deve essere "normal" o "high"');
596
+ }
597
+ const urgency = args.urgency === 'high' ? 'high' : 'normal';
598
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
599
+ const identity = await ctx.identity();
600
+ const session = requireSession(identity.session, 'nc_speak_group', identity.code);
601
+ const receipt = await ctx.api('POST', '/api/audio/groups/speak', {
602
+ group, text, urgency,
603
+ ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}),
604
+ }, { signedSession: session });
605
+ return { receipt };
606
+ },
607
+ },
608
+ {
609
+ name: 'nc_speak_group_status',
610
+ description: 'Interroga lo status per-endpoint di un gruppo audio (read-only). Il receipt e caller-scoped e non contiene testo, lingua, voce o successi aggregati.',
611
+ inputSchema: {
612
+ type: 'object',
613
+ properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo' } },
614
+ required: ['utteranceId'],
615
+ },
616
+ annotations: { readOnlyHint: true },
617
+ async handler(args, ctx) {
618
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
619
+ const identity = await ctx.identity();
620
+ const session = requireSession(identity.session, 'nc_speak_group_status', identity.code);
621
+ const receipt = await ctx.api('GET', `/api/audio/groups/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
622
+ return { receipt };
623
+ },
624
+ },
625
+ {
626
+ name: 'nc_speak_group_stop',
627
+ description: 'Ferma un gruppo audio per utteranceId: blocca i candidati non ancora tentati e invia Stop a ogni endpoint gia ammesso. Lo Stop locale del nodo resta sovrano anche offline.',
628
+ inputSchema: {
629
+ type: 'object',
630
+ properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo da fermare' } },
631
+ required: ['utteranceId'],
632
+ },
633
+ async handler(args, ctx) {
634
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
635
+ const identity = await ctx.identity();
636
+ const session = requireSession(identity.session, 'nc_speak_group_stop', identity.code);
637
+ const receipt = await ctx.api('POST', '/api/audio/groups/stop', { utteranceId }, { signedSession: session });
638
+ return { receipt };
639
+ },
640
+ },
499
641
  ];
500
642
 
501
643
  module.exports = {
@@ -416,36 +416,46 @@ async function nodesTest(opts) {
416
416
  return { code: 1, result: 'tunnel-down', diagnostic };
417
417
  }
418
418
 
419
- const httpProbe = opts.httpProbe || defaultHttpProbe;
420
- const base = `http://127.0.0.1:${node.localPort}`;
421
-
422
- // health: GET / non autenticato -> 2xx significa "server remoto raggiungibile via tunnel".
423
- let health;
424
- try { health = await httpProbe(`${base}/`, {}); }
425
- catch (e) { health = { ok: false, error: e && e.message }; }
426
- if (!health || !health.ok) {
427
- const diagnostic = tunnel.diagnoseTunnel(home, node);
428
- const realCause = diagnostic.code !== 'transport-ready' ? diagnostic.detail : ((health && health.error) || 'server HTTP non raggiungibile');
429
- log(`nodes test [${name}]: HEALTH KO — ${realCause}${diagnostic.hint ? ` · ${diagnostic.hint}` : ''}`);
430
- return { code: 1, result: 'health-ko', diagnostic: diagnostic.code === 'transport-ready'
431
- ? { stage: 'http', code: 'peer-http-unreachable', detail: realCause, hint: 'verifica la porta NexusCrew contenuta nel link' }
432
- : diagnostic };
433
- }
434
-
435
- // token: GET /api/config con Bearer del nodo -> 200 ok, 401 token KO.
436
419
  if (!node.token) {
437
420
  log(`nodes test [${name}]: ASSOCIAZIONE INCOMPLETA — rimuovi il nodo e ripeti il pairing dalla PWA`);
438
421
  return { code: 1, result: 'token-missing' };
439
422
  }
440
- let authed;
441
- try { authed = await httpProbe(`${base}/api/config`, { authorization: `Bearer ${node.token}` }); }
442
- catch (e) { authed = { ok: false, error: e && e.message }; }
443
- if (authed && authed.status === 200) {
444
- log(`nodes test [${name}]: OK tunnel up, health ok, token valido`);
445
- return { code: 0, result: 'ok' };
423
+
424
+ // The per-peer token is a federation credential accepted by the remote
425
+ // peer's acceptToken. It is deliberately NOT the remote browser/UI token,
426
+ // so probing /api/config with it always returns a misleading 401. Use the
427
+ // same authenticated, identity-bound health endpoint as the live topology.
428
+ const probe = opts.federationProbe || federation.probeHealth;
429
+ let health;
430
+ try {
431
+ health = await probe({
432
+ port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null,
433
+ fetchImpl: opts.fetchImpl, timeoutMs: opts.timeoutMs,
434
+ });
435
+ } catch (_) {
436
+ health = {
437
+ transport: 'down', auth: 'unknown', reachability: 'unknown',
438
+ status: 'down', detail: 'peer non raggiungibile',
439
+ };
440
+ }
441
+ if (health && health.status === 'healthy') {
442
+ log(`nodes test [${name}]: OK — tunnel e federation autenticati`);
443
+ return { code: 0, result: 'ok', health };
446
444
  }
447
- log(`nodes test [${name}]: CREDENZIALE KO — il pairing non e' piu' valido (status ${(authed && authed.status) || '?'}); ripeti il pairing dalla PWA`);
448
- return { code: 1, result: 'token-ko' };
445
+ if (health && health.auth === 'failed') {
446
+ log(`nodes test [${name}]: CREDENZIALE KO — ${health.detail || 'federation 401'}; ripeti il pairing dalla PWA`);
447
+ return { code: 1, result: 'token-ko', health };
448
+ }
449
+ const diagnostic = tunnel.diagnoseTunnel(home, node);
450
+ const detail = (health && health.detail)
451
+ || (diagnostic.code !== 'transport-ready' ? diagnostic.detail : 'peer federation non raggiungibile');
452
+ log(`nodes test [${name}]: HEALTH KO — ${detail}${diagnostic.hint ? ` · ${diagnostic.hint}` : ''}`);
453
+ return {
454
+ code: 1, result: 'health-ko', health,
455
+ diagnostic: diagnostic.code === 'transport-ready'
456
+ ? { stage: 'http', code: 'peer-federation-unreachable', detail, hint: 'verifica il tunnel e la porta NexusCrew contenuta nel link' }
457
+ : diagnostic,
458
+ };
449
459
  }
450
460
 
451
461
  // probe HTTP di default (fetch built-in, Node >=18). Ritorna {ok, status}.
@@ -21,6 +21,17 @@ const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw
21
21
  const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
22
22
  const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
23
23
  ? Math.min(reverseFailureMaxRaw, 32) : 8;
24
+ // Once the initial reverse-forward budget is exhausted the supervisor does NOT
25
+ // die: it stays "degraded" and retries at the fixed production cadence of 60s,
26
+ // so a transient reverse listener on the hub (e.g. a mobile reconnect) heals on
27
+ // its own without an OFF/ON. A short cadence is a test-only seam, never a
28
+ // production runtime override: otherwise a bad environment value could recreate
29
+ // the retry storm that this breaker is meant to prevent.
30
+ const steadyRetryTestMode = process.env.NEXUSCREW_TUNNEL_TEST_MODE === '1';
31
+ const steadyRetryMsRaw = Number(steadyRetryTestMode ? (process.env.NEXUSCREW_TUNNEL_STEADY_RETRY_MS || 60000) : 60000);
32
+ const steadyRetryMinMs = steadyRetryTestMode ? 1 : 60000;
33
+ const steadyRetryMs = Number.isFinite(steadyRetryMsRaw) && steadyRetryMsRaw >= steadyRetryMinMs
34
+ ? Math.min(steadyRetryMsRaw, 120000) : 60000;
24
35
  if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
25
36
 
26
37
  let child = null;
@@ -92,6 +103,7 @@ function probeForward(expectedChild) {
92
103
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
93
104
  if (ready) {
94
105
  attempt = 0;
106
+ reverseFailures = 0;
95
107
  logEvent(`forward ready stableMs=${stableMs}`);
96
108
  if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
97
109
  return;
@@ -137,14 +149,28 @@ function scheduleRetry(detail) {
137
149
  retryTimer = setTimeout(run, delayMs);
138
150
  }
139
151
 
140
- function terminalFailure(diagnosis) {
152
+ // Bounded, attributable degraded state. The supervisor stays alive, advertises
153
+ // the reverse failure class plus the negotiated reversePort and the honest
154
+ // remote-listener attribution (unknown without hub privilege), then retries on
155
+ // a fixed cadence. It never exits on a reverse-forward failure: the tunnel
156
+ // self-heals when the channel frees up.
157
+ function enterDegraded(diagnosis) {
158
+ if (stopping) return finish();
141
159
  const safe = diagnosis || { code: 'reverse-forward-failed', detail: 'canale inverso non disponibile' };
142
- logEvent(`ssh terminal failure code=${safe.code} attempts=${reverseFailures}`);
143
- writeState('failed', {
160
+ if (reverseFailures > reverseFailureMax) reverseFailures = reverseFailureMax;
161
+ // `ownsGeneration()` only proves that THIS local supervisor owns its pidfile;
162
+ // it cannot identify the process holding a listener on the hub. Never turn
163
+ // local pidfile ownership into a false remote-listener attribution.
164
+ const ownership = 'unknown';
165
+ logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${reverseFailures} steadyRetryMs=${steadyRetryMs}`);
166
+ if (!writeState('degraded', {
144
167
  code: safe.code, detail: safe.detail,
145
- ...(safe.hint ? { hint: safe.hint } : {}), terminal: true,
146
- });
147
- process.exit(0);
168
+ ...(safe.hint ? { hint: safe.hint } : {}),
169
+ reversePort, ownership, steadyRetryMs, terminal: false,
170
+ })) return stop();
171
+ // Keep the backoff counter honest for observers; the degraded retry is fixed.
172
+ if (attempt < reverseFailureMax) attempt = reverseFailureMax;
173
+ retryTimer = setTimeout(run, steadyRetryMs);
148
174
  }
149
175
 
150
176
  function run() {
@@ -176,7 +202,7 @@ function run() {
176
202
  const reverseFailure = diagnosis && ['reverse-forward-bind', 'reverse-forward-failed'].includes(diagnosis.code);
177
203
  if (reverseFailure) {
178
204
  reverseFailures += 1;
179
- if (reverseFailures >= reverseFailureMax) return terminalFailure(diagnosis);
205
+ if (reverseFailures >= reverseFailureMax) return enterDegraded(diagnosis);
180
206
  } else {
181
207
  reverseFailures = 0;
182
208
  }
@@ -290,6 +290,19 @@ function readTunnelState(home, name) {
290
290
  const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
291
291
  if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
292
292
  if (state.status === 'failed' && state.terminal === true) return terminalState();
293
+ if (state.status === 'degraded') {
294
+ return {
295
+ status: 'down', phase: 'degraded', reason: 'degraded', pid: meta.pid, since: meta.startTs || null,
296
+ ...(typeof state.code === 'string' ? { code: state.code } : {}),
297
+ ...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
298
+ ...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
299
+ ...(Number.isInteger(state.reversePort) ? { reversePort: state.reversePort } : {}),
300
+ ...(typeof state.ownership === 'string' ? { ownership: state.ownership } : {}),
301
+ ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
302
+ ...(Number.isFinite(state.steadyRetryMs) ? { retryInMs: state.steadyRetryMs } : {}),
303
+ ...(transport ? { transport } : {}),
304
+ };
305
+ }
293
306
  if (state.status === 'transport-ready' || state.status === 'up') {
294
307
  return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
295
308
  ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
@@ -330,6 +343,14 @@ function diagnoseTunnel(home, node, state = null) {
330
343
  detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
331
344
  hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
332
345
  }
346
+ if (current.reason === 'degraded' || current.phase === 'degraded') {
347
+ return {
348
+ stage: 'ssh', code: current.code || 'reverse-forward-failed', status: 'down', phase: 'degraded',
349
+ detail: current.detail || 'canale inverso non disponibile, retry fisso in corso',
350
+ hint: current.hint || 'il tunnel resta attivo e riprovera automaticamente; verifica il listener reverse sul hub',
351
+ ...(Number.isInteger(current.reversePort) ? { reversePort: current.reversePort } : {}),
352
+ };
353
+ }
333
354
  if (current.reason === 'failed' || current.phase === 'failed') {
334
355
  return {
335
356
  stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
@@ -10,6 +10,7 @@ const { safeEqual } = require('../nodes/peering.js');
10
10
  const {
11
11
  sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
12
12
  } = require('./node-proxy.js');
13
+ const { signHop, HOP_HEADER } = require('./hop-proof.js');
13
14
 
14
15
  const MAX_HOPS = 4;
15
16
  const ROUTE_DELIMITER = '_';
@@ -71,6 +72,10 @@ function knownResource(resource) {
71
72
  // Hydra: the rest of /settings stays unreachable.
72
73
  || resource === '/settings/peering/invite'
73
74
  || resource === '/ws'
75
+ || resource === '/audio/capability'
76
+ || resource === '/audio/speak'
77
+ || resource === '/audio/speak/status'
78
+ || resource === '/audio/stop'
74
79
  || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
75
80
  }
76
81
 
@@ -97,9 +102,25 @@ function allowedResource(resource, method = 'GET') {
97
102
  if (resource === '/ws') return method === 'GET';
98
103
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
99
104
  if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
105
+ // Audio: only capability read, speak and stop are exposed through Hydra.
106
+ // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
107
+ if (resource === '/audio/capability') return method === 'GET';
108
+ if (resource === '/audio/speak') return method === 'POST';
109
+ if (resource === '/audio/speak/status') return method === 'POST';
110
+ if (resource === '/audio/stop') return method === 'POST';
100
111
  return false;
101
112
  }
102
113
 
114
+ // READONLY blocca ogni mutazione che crea o modifica stato, con due eccezioni
115
+ // audio deliberate: interrogare lo status federato usa POST soltanto per
116
+ // trasportare l'attestazione della cella, e Stop e' un'azione di sicurezza che
117
+ // deve poter zittire un endpoint anche quando quel nodo e' READONLY. Speak
118
+ // resta naturalmente bloccato.
119
+ function readonlyBlocksFederated(resource, method) {
120
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
121
+ return resource !== '/audio/speak/status' && resource !== '/audio/stop';
122
+ }
123
+
103
124
  function allowedQuery(resource, method, rawUrl) {
104
125
  if (!resource.startsWith('/diagnostics/')) return true;
105
126
  const index = String(rawUrl || '').indexOf('?');
@@ -117,17 +138,20 @@ function allowedQuery(resource, method, rawUrl) {
117
138
  return true;
118
139
  }
119
140
 
120
- function cleanHeaders(headers, credential, visited = null) {
141
+ function cleanHeaders(headers, credential, visited = null, hopProof = null) {
121
142
  const out = sanitizeRequestHeaders(headers, credential);
122
143
  for (const key of Object.keys(out)) {
123
144
  if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
124
145
  }
125
146
  if (Array.isArray(visited) && visited.length) out['x-nexuscrew-visited'] = visited.join(',');
147
+ // La prova di hop viene aggiunta DOPO la cancellazione: il canale e' riservato
148
+ // al server, un valore arrivato dal client non sopravvive mai fin qui.
149
+ if (hopProof) out[HOP_HEADER] = hopProof;
126
150
  return out;
127
151
  }
128
152
 
129
- function proxyHttp(req, res, { port, path, credential, visited = null }) {
130
- const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited) }, (r) => {
153
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null }) {
154
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof) }, (r) => {
131
155
  res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
132
156
  });
133
157
  up.setTimeout(30000, () => up.destroy());
@@ -135,22 +159,28 @@ function proxyHttp(req, res, { port, path, credential, visited = null }) {
135
159
  req.pipe(up);
136
160
  }
137
161
 
138
- function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
162
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
139
163
  return (req, res) => {
140
164
  const parsed = parseRoute(req.url);
141
165
  if (!parsed || !allowedResource(parsed.resource, req.method)
142
166
  || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
143
- if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
167
+ if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
144
168
  const st = store.loadStore(nodesPath);
145
169
  if (!st) return res.status(503).json({ error: 'node store unavailable' });
146
170
  const visited = controlledVisited(req, ingress, st.nodeId);
147
171
  if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
148
172
  if (parsed.route.length === 0) {
173
+ // Ultimo hop: la richiesta rientra nell'API locale con il Bearer locale e
174
+ // da li' in poi sarebbe indistinguibile da un POST diretto. La prova di
175
+ // hop e' cio' che la distingue, ed e' legata a metodo, path e catena.
176
+ const path = `/api${parsed.resource}${queryOf(req.url)}`;
177
+ const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
149
178
  return proxyHttp(req, res, {
150
179
  port: typeof localPort === 'function' ? localPort() : localPort,
151
- path: `/api${parsed.resource}${queryOf(req.url)}`,
180
+ path,
152
181
  credential: localCredential(),
153
182
  visited,
183
+ hopProof: signHop(secret, { method: req.method, path, visited }),
154
184
  });
155
185
  }
156
186
  const next = st && store.getNode(st, parsed.route[0]);
@@ -514,7 +544,7 @@ async function collectLocalTopology({
514
544
  return { instanceId: live.instanceId, nodes };
515
545
  }
516
546
 
517
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null }) {
547
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
518
548
  const r = express.Router();
519
549
  r.use((req, res, next) => {
520
550
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -576,13 +606,13 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
576
606
  return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
577
607
  }
578
608
  });
579
- r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
609
+ r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly, hopSecret })(req, res));
580
610
  return r;
581
611
  }
582
612
 
583
- function localRouter({ nodesPath, localPort, localCredential, readonly }) {
613
+ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null }) {
584
614
  const r = express.Router();
585
- r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly })(req, res));
615
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret })(req, res));
586
616
  return r;
587
617
  }
588
618
 
@@ -624,7 +654,7 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
624
654
 
625
655
  module.exports = {
626
656
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
627
- peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
657
+ peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
628
658
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
629
659
  probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
630
660
  };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ // lib/proxy/hop-proof.js — prova che una richiesta arrivata su /api e' l'ULTIMO
3
+ // hop di una route federata generata da QUESTO processo, non un POST diretto di
4
+ // un client locale che ha semplicemente scritto gli header a mano.
5
+ //
6
+ // Perche' serve. `controlledVisited()` lega gia' `x-nexuscrew-visited` al peer
7
+ // autenticato dal suo acceptToken, ma l'ultimo hop entra in `/api/<resource>`
8
+ // con il Bearer LOCALE: a quel punto l'handler non distingue piu' un inbound
9
+ // federato da una richiesta diretta di chiunque possieda il token della UI.
10
+ // `cleanHeaders()` cancella gia' `x-nexuscrew-{route,visited,hop}` da ogni
11
+ // header in ingresso, quindi il canale e' riservato: basta renderlo non
12
+ // riproducibile. La firma usa un segreto casuale per-processo, mai su disco,
13
+ // mai esposto da un'API: un client locale non puo' calcolarla.
14
+ //
15
+ // Il payload firmato include metodo e path oltre alla catena visited, cosi' una
16
+ // prova non e' trasportabile su un'altra risorsa o su un altro verbo.
17
+ const crypto = require('node:crypto');
18
+
19
+ const HOP_HEADER = 'x-nexuscrew-hop';
20
+ const PREFIX = 'NEXUSCREW-HOP-V1';
21
+
22
+ function createHopSecret() {
23
+ return crypto.randomBytes(32);
24
+ }
25
+
26
+ function canonicalHop({ method, path: reqPath, visited }) {
27
+ const chain = Array.isArray(visited) ? visited.join(',') : String(visited || '');
28
+ return `${PREFIX}\n${String(method || '').toUpperCase()}\n${String(reqPath || '')}\n${chain}`;
29
+ }
30
+
31
+ // signHop(): hex HMAC-SHA256. Ritorna null senza segreto, cosi' un server che
32
+ // non ha inizializzato la prova non emette un header vuoto interpretabile.
33
+ function signHop(secret, parts) {
34
+ if (!secret) return null;
35
+ return crypto.createHmac('sha256', secret).update(canonicalHop(parts)).digest('hex');
36
+ }
37
+
38
+ // verifyHop(): confronto timing-safe. Fail-closed su qualunque anomalia
39
+ // (segreto assente, header assente, lunghezza diversa, hex non valido).
40
+ function verifyHop(secret, parts, proof) {
41
+ if (!secret || typeof proof !== 'string' || !/^[a-f0-9]{64}$/i.test(proof)) return false;
42
+ const expected = signHop(secret, parts);
43
+ if (!expected) return false;
44
+ const a = Buffer.from(expected, 'hex');
45
+ const b = Buffer.from(proof.toLowerCase(), 'hex');
46
+ if (a.length !== b.length) return false;
47
+ return crypto.timingSafeEqual(a, b);
48
+ }
49
+
50
+ module.exports = { createHopSecret, signHop, verifyHop, canonicalHop, HOP_HEADER };