@mmmbuto/nexuscrew 0.8.39 → 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/CHANGELOG.md +33 -1
- package/README.md +11 -1
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/acl.js +65 -0
- package/lib/audio/adapters.js +233 -0
- package/lib/audio/bridge-auth.js +176 -0
- package/lib/audio/capability.js +40 -0
- package/lib/audio/consent.js +64 -0
- package/lib/audio/dispatch.js +154 -0
- package/lib/audio/group-receipt.js +119 -0
- package/lib/audio/group-speak.js +146 -0
- package/lib/audio/groups.js +121 -0
- package/lib/audio/origin.js +128 -0
- package/lib/audio/queue.js +191 -0
- package/lib/audio/rate-limit.js +71 -0
- package/lib/audio/receipt.js +146 -0
- package/lib/audio/routes.js +374 -0
- package/lib/audio/speak.js +125 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/mcp/server.js +27 -3
- package/lib/mcp/tools.js +142 -0
- package/lib/nodes/tunnel-supervisor.js +33 -7
- package/lib/nodes/tunnel.js +21 -0
- package/lib/proxy/federation.js +41 -11
- package/lib/proxy/hop-proof.js +50 -0
- package/lib/server.js +93 -2
- package/lib/settings/routes.js +131 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-D0MBXlAl.js +0 -93
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
|
-
|
|
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
|
-
...
|
|
196
|
+
...signed,
|
|
197
|
+
...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
174
198
|
},
|
|
175
|
-
...(
|
|
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 = {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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 } : {}),
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
205
|
+
if (reverseFailures >= reverseFailureMax) return enterDegraded(diagnosis);
|
|
180
206
|
} else {
|
|
181
207
|
reverseFailures = 0;
|
|
182
208
|
}
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -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',
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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() &&
|
|
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
|
|
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 };
|
package/lib/server.js
CHANGED
|
@@ -32,6 +32,17 @@ const nodesInventory = require('./nodes/inventory.js');
|
|
|
32
32
|
const topologyCache = require('./nodes/topology-cache.js');
|
|
33
33
|
const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
|
|
34
34
|
const federation = require('./proxy/federation.js');
|
|
35
|
+
const { audioRoutes } = require('./audio/routes.js');
|
|
36
|
+
const { isConsent: isAudioConsent } = require('./audio/consent.js');
|
|
37
|
+
const { createOriginResolver } = require('./audio/origin.js');
|
|
38
|
+
const { createAudioAcl } = require('./audio/acl.js');
|
|
39
|
+
const { createDispatcher } = require('./audio/dispatch.js');
|
|
40
|
+
const { createSpeakQueue } = require('./audio/queue.js');
|
|
41
|
+
const { createReceiptStore } = require('./audio/receipt.js');
|
|
42
|
+
const audioAdapters = require('./audio/adapters.js');
|
|
43
|
+
const audioGroups = require('./audio/groups.js');
|
|
44
|
+
const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
|
|
45
|
+
const { createHopSecret } = require('./proxy/hop-proof.js');
|
|
35
46
|
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
36
47
|
const decksStore = require('./decks/store.js');
|
|
37
48
|
const { decksRoutes } = require('./decks/routes.js');
|
|
@@ -133,6 +144,22 @@ function createServer(opts = {}) {
|
|
|
133
144
|
const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
|
|
134
145
|
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
135
146
|
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
147
|
+
const audioHome = cfg.home || os.homedir();
|
|
148
|
+
// Stato audio ancorato alla directory del token (come notifyDir): server,
|
|
149
|
+
// Settings e bridge MCP devono guardare gli stessi file anche quando un test
|
|
150
|
+
// isola l'istanza fuori dalla home reale.
|
|
151
|
+
const audioCfg = {
|
|
152
|
+
home: audioHome,
|
|
153
|
+
tokenPath: cfg.tokenPath,
|
|
154
|
+
audioConsentPath: cfg.audioConsentPath,
|
|
155
|
+
audioGroupsPath: cfg.audioGroupsPath,
|
|
156
|
+
audioBridgeSecretPath: cfg.audioBridgeSecretPath || path.join(notifyDir, 'audio-bridge.key'),
|
|
157
|
+
};
|
|
158
|
+
// Segreto per-processo della prova di hop federata: vive solo in memoria, non
|
|
159
|
+
// e' su disco e nessuna API lo espone. Serve a distinguere l'ultimo hop di una
|
|
160
|
+
// route federata da un POST diretto di chi possiede il token della UI.
|
|
161
|
+
const hopSecret = createHopSecret();
|
|
162
|
+
const audioNonceCache = createNonceCache();
|
|
136
163
|
function resolveNode(name) {
|
|
137
164
|
const st = nodesStore.loadStore(nodesPath);
|
|
138
165
|
if (!st) return null;
|
|
@@ -299,6 +326,64 @@ function createServer(opts = {}) {
|
|
|
299
326
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
300
327
|
}));
|
|
301
328
|
api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
|
|
329
|
+
// Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
|
|
330
|
+
// store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
|
|
331
|
+
// asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
|
|
332
|
+
// risoluzione dell'origine sempre vuota e la feature morta senza che un test
|
|
333
|
+
// sul router se ne accorga.
|
|
334
|
+
const audioNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
335
|
+
// Seam di test per l'adapter: permette di esercitare il percorso completo
|
|
336
|
+
// (route -> gate -> coda -> receipt) sul server REALE senza emettere suono.
|
|
337
|
+
// In produzione resta il probe locale.
|
|
338
|
+
const audioAdapter = cfg.audioAdapterSeam
|
|
339
|
+
|| audioAdapters.createAdapter(audioAdapters.detectAdapter({ env: cfg.env || process.env }));
|
|
340
|
+
const audioReceipts = createReceiptStore();
|
|
341
|
+
const audioQueue = createSpeakQueue({
|
|
342
|
+
adapter: audioAdapter,
|
|
343
|
+
// La coda e' l'unica a sapere com'e' finito davvero un enunciato: e' lei ad
|
|
344
|
+
// aggiornare il receipt da `accepted` a spoken/refused/unknown.
|
|
345
|
+
onStatus: (utteranceId, status, reason) => { try { audioReceipts.update(utteranceId, status, reason); } catch (_) {} },
|
|
346
|
+
});
|
|
347
|
+
api.use('/audio', audioRoutes({
|
|
348
|
+
readonly: proxyReadonly,
|
|
349
|
+
localNodeId: audioNodeId,
|
|
350
|
+
receiptStore: audioReceipts,
|
|
351
|
+
adapter: audioAdapter,
|
|
352
|
+
queue: audioQueue,
|
|
353
|
+
acl: createAudioAcl({ nodesPath }),
|
|
354
|
+
consent: () => { try { return isAudioConsent(audioCfg, audioHome); } catch (_) { return false; } },
|
|
355
|
+
originResolver: createOriginResolver({
|
|
356
|
+
localNodeId: audioNodeId,
|
|
357
|
+
// Celle Fleet ATTIVE in questo momento, attese davvero.
|
|
358
|
+
activeCells: async () => {
|
|
359
|
+
const fleet = await fleetP;
|
|
360
|
+
if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') return [];
|
|
361
|
+
const st = await fleet.status();
|
|
362
|
+
return Array.isArray(st && st.cells) ? st.cells.map((c) => ({
|
|
363
|
+
cell: c && c.cell, tmuxSession: c && c.tmuxSession,
|
|
364
|
+
active: c && c.active === true && c.tmux !== false,
|
|
365
|
+
})) : [];
|
|
366
|
+
},
|
|
367
|
+
bridgeSecret: () => { try { return loadOrCreateBridgeSecret(bridgeSecretPath(audioCfg, audioHome)); } catch (_) { return null; } },
|
|
368
|
+
hopSecret: () => hopSecret,
|
|
369
|
+
nonceCache: audioNonceCache,
|
|
370
|
+
}),
|
|
371
|
+
dispatcher: createDispatcher({
|
|
372
|
+
localNodeId: audioNodeId,
|
|
373
|
+
peers: async () => {
|
|
374
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
375
|
+
if (!st) return [];
|
|
376
|
+
const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
|
|
377
|
+
return nodesInventory.buildInventory({
|
|
378
|
+
direct: nodesStore.redactStore(st).nodes,
|
|
379
|
+
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
383
|
+
localToken: () => tokenHolder.value,
|
|
384
|
+
}),
|
|
385
|
+
getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
|
|
386
|
+
}));
|
|
302
387
|
api.use('/cells', cellsRoutes({
|
|
303
388
|
fleetP,
|
|
304
389
|
instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
@@ -359,7 +444,12 @@ function createServer(opts = {}) {
|
|
|
359
444
|
// Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
|
|
360
445
|
// wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
|
|
361
446
|
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
362
|
-
api.use('/settings', settingsRoutes({
|
|
447
|
+
api.use('/settings', settingsRoutes({
|
|
448
|
+
cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort,
|
|
449
|
+
// Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
|
|
450
|
+
// locale a frase fissa e lo stop sovrano, mai una seconda sintesi parallela.
|
|
451
|
+
audio: { adapter: audioAdapter, queue: audioQueue },
|
|
452
|
+
}));
|
|
363
453
|
api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
|
|
364
454
|
api.get('/topology', async (_req, res) => {
|
|
365
455
|
try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
|
|
@@ -367,6 +457,7 @@ function createServer(opts = {}) {
|
|
|
367
457
|
});
|
|
368
458
|
api.use('/route', federation.localRouter({
|
|
369
459
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
460
|
+
hopSecret: () => hopSecret,
|
|
370
461
|
}));
|
|
371
462
|
api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
|
|
372
463
|
api.post('/voice/transcribe',
|
|
@@ -392,7 +483,7 @@ function createServer(opts = {}) {
|
|
|
392
483
|
app.use('/api', api);
|
|
393
484
|
app.use('/federation', federation.peerRouter({
|
|
394
485
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
395
|
-
fetchImpl: healthFetch,
|
|
486
|
+
fetchImpl: healthFetch, hopSecret: () => hopSecret,
|
|
396
487
|
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
397
488
|
}));
|
|
398
489
|
|