@mmmbuto/nexuscrew 0.9.56 → 0.9.58
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 +17 -0
- package/frontend/dist/assets/index-D8cf4AEo.js +169 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/managed.js +57 -0
- package/lib/nodes/access-presets.js +1 -0
- package/lib/nodes/ask-slot-verifier.js +72 -0
- package/lib/nodes/commands.js +7 -3
- package/lib/nodes/reverse-slot-proof.js +19 -7
- package/lib/nodes/store.js +35 -5
- package/lib/notify/ask-answer-service.js +23 -11
- package/lib/notify/ask-receipts.js +1 -0
- package/lib/notify/ask-relay.js +210 -73
- package/lib/notify/event-feed-asks-routes.js +62 -15
- package/lib/notify/event-feed-client.js +120 -46
- package/lib/notify/routes.js +32 -17
- package/lib/proxy/federation.js +112 -12
- package/lib/proxy/resource-acl.js +1 -0
- package/lib/server.js +53 -20
- package/lib/tmux/actions.js +14 -11
- package/package.json +1 -1
- package/frontend/dist/assets/index-CJqN29CA.js +0 -169
package/lib/proxy/federation.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { createAskSlotVerifier, askSlotVerifier, binding: askSlotBinding } = require('../nodes/ask-slot-verifier.js');
|
|
3
4
|
const http = require('node:http');
|
|
4
5
|
const net = require('node:net');
|
|
5
6
|
const crypto = require('node:crypto');
|
|
6
7
|
const express = require('express');
|
|
7
8
|
const { WebSocketServer } = require('ws');
|
|
8
9
|
const store = require('../nodes/store.js');
|
|
10
|
+
const accessPresets = require('../nodes/access-presets.js');
|
|
9
11
|
const topologyCache = require('../nodes/topology-cache.js');
|
|
10
12
|
const { allowedByClass, classifyResource } = require('./resource-acl.js');
|
|
11
13
|
const reverseRotation = require('../nodes/reverse-rotation.js');
|
|
@@ -223,7 +225,7 @@ function knownResource(resource) {
|
|
|
223
225
|
|| resource === '/event-feed'
|
|
224
226
|
|| resource === '/event-feed/snapshot'
|
|
225
227
|
// Federated ask write-back: answer, dismiss, attempt status.
|
|
226
|
-
|| /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
|
|
228
|
+
|| /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/capability|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
|
|
227
229
|
|| /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
|
|
228
230
|
|| /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
|
|
229
231
|
|| isPanelResource(resource)
|
|
@@ -330,6 +332,7 @@ function allowedResource(resource, method = 'GET') {
|
|
|
330
332
|
if (/^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)) return method === 'GET';
|
|
331
333
|
if (/^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)) return method === 'GET';
|
|
332
334
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return method === 'POST';
|
|
335
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/capability$/.test(resource)) return method === 'GET';
|
|
333
336
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return method === 'DELETE';
|
|
334
337
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return method === 'GET';
|
|
335
338
|
if (resource === '/asks') return method === 'POST';
|
|
@@ -477,19 +480,23 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
|
|
|
477
480
|
const SSE_IDLE_TIMEOUT_MS = 90000;
|
|
478
481
|
const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
|
|
479
482
|
|
|
480
|
-
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
|
|
483
|
+
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS, onForwardFailure = null, abortWithClient = false }) {
|
|
481
484
|
// A body a local middleware has already read is no longer in the stream: the
|
|
482
485
|
// headers would announce bytes that nothing sends, and the peer would wait for
|
|
483
486
|
// a body that never arrives. Say it at once, with a name, instead of letting
|
|
484
487
|
// the forward sit until the upstream timeout expires.
|
|
485
488
|
if (Number(req.headers['content-length'] || 0) > 0 && req.readableEnded) {
|
|
489
|
+
if (onForwardFailure) onForwardFailure();
|
|
486
490
|
return res.status(502).json({ error: 'request body already read by a local middleware', reason: 'federation-body-consumed' });
|
|
487
491
|
}
|
|
492
|
+
const failed = () => { if (onForwardFailure) onForwardFailure(); };
|
|
488
493
|
let streamWatchdog = null;
|
|
489
494
|
const clearStreamWatchdog = () => {
|
|
490
495
|
if (streamWatchdog) { clearTimeout(streamWatchdog); streamWatchdog = null; }
|
|
491
496
|
};
|
|
492
497
|
const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
|
|
498
|
+
if (r.statusCode < 200 || r.statusCode >= 300) failed();
|
|
499
|
+
r.once('aborted', failed); r.once('error', failed);
|
|
493
500
|
const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
|
|
494
501
|
if (!capture) {
|
|
495
502
|
res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
|
|
@@ -534,8 +541,13 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
534
541
|
// quando il tunnel del peer è «su a metà» (socket aperto, nessuna risposta):
|
|
535
542
|
// 10 s e 504 — prima era un unico 30 s che chiudeva con 502. Gli altri
|
|
536
543
|
// metodi mantengono il tetto largo di prima.
|
|
544
|
+
if (abortWithClient) {
|
|
545
|
+
req.once('aborted', () => { failed(); up.destroy(); });
|
|
546
|
+
res.once('close', () => { if (!res.writableFinished) { failed(); up.destroy(); } });
|
|
547
|
+
}
|
|
537
548
|
const FORWARD_TIMEOUT_MS = req.method === 'GET' ? getTimeoutMs : 30000;
|
|
538
549
|
up.setTimeout(FORWARD_TIMEOUT_MS, () => {
|
|
550
|
+
failed();
|
|
539
551
|
if (!res.headersSent) {
|
|
540
552
|
res.status(504).json({ error: 'peer non risponde entro il timeout', reason: 'federation-peer-timeout' });
|
|
541
553
|
up.destroy();
|
|
@@ -545,6 +557,7 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
545
557
|
});
|
|
546
558
|
up.on('close', clearStreamWatchdog);
|
|
547
559
|
up.on('error', () => {
|
|
560
|
+
failed();
|
|
548
561
|
clearStreamWatchdog();
|
|
549
562
|
if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' });
|
|
550
563
|
else res.destroy();
|
|
@@ -552,9 +565,20 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
552
565
|
req.pipe(up);
|
|
553
566
|
}
|
|
554
567
|
|
|
555
|
-
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000, forwardSseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
|
|
556
|
-
|
|
568
|
+
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000, forwardSseIdleMs = SSE_IDLE_TIMEOUT_MS, askProofNow = null, slotVerifier = null }) {
|
|
569
|
+
const verifier = slotVerifier || (askProofNow ? createAskSlotVerifier({ now: askProofNow }) : askSlotVerifier);
|
|
570
|
+
return async (req, res) => {
|
|
557
571
|
const parsed = parseRoute(req.url);
|
|
572
|
+
const rejectAskRequest = (status, body) => {
|
|
573
|
+
if (parsed && classifyResource(parsed.resource, req.method) === 'ask-action') {
|
|
574
|
+
const current = store.loadStore(nodesPath);
|
|
575
|
+
verifier.sync(current, nodesPath);
|
|
576
|
+
const peer = current && store.getNode(current, parsed.route[0]);
|
|
577
|
+
const selected = askSlotBinding(current, peer);
|
|
578
|
+
verifier.invalidate(selected && selected.key);
|
|
579
|
+
}
|
|
580
|
+
return res.status(status).json(body);
|
|
581
|
+
};
|
|
558
582
|
// Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
|
|
559
583
|
// risorsa esiste per una cella o per un'altra.
|
|
560
584
|
if (parsed && isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) {
|
|
@@ -571,7 +595,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
571
595
|
if (parsed && allowedResource(parsed.resource, req.method)) {
|
|
572
596
|
const gate = classGateCheck(parsed.resource, req.method, ingress);
|
|
573
597
|
if (!gate.ok) {
|
|
574
|
-
return
|
|
598
|
+
return rejectAskRequest(gate.status, {
|
|
575
599
|
error: gate.status === 404 ? 'not found' : 'risorsa non concessa a questo nodo',
|
|
576
600
|
reason: gate.reason,
|
|
577
601
|
});
|
|
@@ -579,9 +603,11 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
579
603
|
}
|
|
580
604
|
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
581
605
|
|| !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
|
|
582
|
-
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return
|
|
606
|
+
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return rejectAskRequest(403, { error: 'READONLY: federated mutation blocked' });
|
|
583
607
|
const st = store.loadStore(nodesPath);
|
|
584
608
|
if (!st) return res.status(503).json({ error: 'node store unavailable' });
|
|
609
|
+
const isAskAction = classifyResource(parsed.resource, req.method) === 'ask-action';
|
|
610
|
+
if (isAskAction) verifier.sync(st, nodesPath);
|
|
585
611
|
const visited = controlledVisited(req, ingress, st.nodeId);
|
|
586
612
|
if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
|
|
587
613
|
// Vale per l'ultimo hop come per il transito: se si fermasse al primo, una
|
|
@@ -602,11 +628,18 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
602
628
|
panelCookie,
|
|
603
629
|
getTimeoutMs: forwardGetTimeoutMs,
|
|
604
630
|
sseIdleMs: forwardSseIdleMs,
|
|
631
|
+
abortWithClient: isAskAction,
|
|
605
632
|
});
|
|
606
633
|
}
|
|
607
634
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
608
635
|
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
609
|
-
if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return
|
|
636
|
+
if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return rejectAskRequest(403, { error: 'route non consentita' });
|
|
637
|
+
if (parsed.resource === '/asks' && req.method === 'POST') {
|
|
638
|
+
const view = accessPresets.grantsOf(next);
|
|
639
|
+
if (!view.configured || view.label !== 'admin') {
|
|
640
|
+
return res.status(403).json({ error: 'ASK target is not an admin peer', reason: 'ask-target-not-admin' });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
610
643
|
const rest = parsed.route.slice(1);
|
|
611
644
|
const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
|
|
612
645
|
// Verso il browser di questo nodo (ingress nullo) il cookie di visione del
|
|
@@ -622,7 +655,68 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
622
655
|
const capture = liveHostLedger && (parsed.resource === '/live-host/designate' || parsed.resource === '/live-host/clear')
|
|
623
656
|
? liveHostLedgerCapture({ ledger: liveHostLedger, ownerId: next.nodeId, resource: parsed.resource })
|
|
624
657
|
: null;
|
|
625
|
-
|
|
658
|
+
let forwardPort = next.localPort;
|
|
659
|
+
let proofKey = null;
|
|
660
|
+
if (next.direction === 'inbound' && classifyResource(parsed.resource, req.method) === 'ask-action') {
|
|
661
|
+
const rejectForward = (status, body) => { verifier.invalidate(proofKey); return res.status(status).json(body); };
|
|
662
|
+
const unverified = () => rejectForward(404, { error: 'not found', reason: 'reverse-slot-unverified' });
|
|
663
|
+
const channel = (peer) => {
|
|
664
|
+
const pool = peer && peer.reversePool;
|
|
665
|
+
const slot = pool && Array.isArray(pool.slots) && pool.slots[pool.activeSlot];
|
|
666
|
+
if (!slot || slot.state !== 'active' || !Number.isInteger(slot.port) || slot.port < 1 || slot.port > 65535
|
|
667
|
+
|| !Number.isSafeInteger(pool.activeGeneration) || pool.activeGeneration < 1
|
|
668
|
+
|| slot.generation !== pool.activeGeneration || !peer.token || !peer.nodeId) return null;
|
|
669
|
+
return { token: peer.token, nodeId: peer.nodeId, localPort: peer.localPort,
|
|
670
|
+
activeSlot: pool.activeSlot, port: slot.port, generation: pool.activeGeneration };
|
|
671
|
+
};
|
|
672
|
+
const selected = channel(next);
|
|
673
|
+
if (!selected) return unverified();
|
|
674
|
+
let disconnected = req.aborted || res.destroyed;
|
|
675
|
+
const controller = new AbortController();
|
|
676
|
+
const onDisconnect = () => { disconnected = true; controller.abort(); };
|
|
677
|
+
req.once('aborted', onDisconnect);
|
|
678
|
+
res.once('close', onDisconnect);
|
|
679
|
+
let proof;
|
|
680
|
+
try {
|
|
681
|
+
proof = await verifier.verify({ store: st, peer: next, loadStore: () => store.loadStore(nodesPath),
|
|
682
|
+
scope: nodesPath, signal: controller.signal });
|
|
683
|
+
} catch (_) { proof = null; }
|
|
684
|
+
finally {
|
|
685
|
+
req.removeListener('aborted', onDisconnect);
|
|
686
|
+
res.removeListener('close', onDisconnect);
|
|
687
|
+
}
|
|
688
|
+
proofKey = proof && proof.key;
|
|
689
|
+
if (disconnected || req.aborted || res.destroyed) { verifier.invalidate(proofKey); return; }
|
|
690
|
+
if (!proof || !proof.owned) return unverified();
|
|
691
|
+
|
|
692
|
+
const current = store.loadStore(nodesPath);
|
|
693
|
+
const currentNext = current && store.getNode(current, parsed.route[0]);
|
|
694
|
+
if (!current || current.nodeId !== st.nodeId || !currentNext || currentNext.direction !== 'inbound'
|
|
695
|
+
|| JSON.stringify(channel(currentNext)) !== JSON.stringify(selected)) return unverified();
|
|
696
|
+
// The proof awaited remote I/O: none of the authorization held above is
|
|
697
|
+
// retained across that wait. Recheck the ingress credential and grants.
|
|
698
|
+
const currentIngress = ingress ? peerFromToken(nodesPath, bearerFrom(req)) : null;
|
|
699
|
+
if (ingress && (!currentIngress || currentIngress.name !== ingress.name || currentIngress.nodeId !== ingress.nodeId)) {
|
|
700
|
+
return rejectForward(401, { error: 'unauthorized peer' });
|
|
701
|
+
}
|
|
702
|
+
const gate = classGateCheck(parsed.resource, req.method, currentIngress);
|
|
703
|
+
if (!gate.ok) return rejectForward(gate.status, { error: 'risorsa non concessa a questo nodo', reason: gate.reason });
|
|
704
|
+
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) {
|
|
705
|
+
return rejectForward(403, { error: 'READONLY: federated mutation blocked' });
|
|
706
|
+
}
|
|
707
|
+
if (!currentNext.token || currentNext.shared !== true || (currentIngress && !canTransit(currentIngress, currentNext))) {
|
|
708
|
+
return rejectForward(403, { error: 'route non consentita' });
|
|
709
|
+
}
|
|
710
|
+
if (!controlledVisited(req, currentIngress, current.nodeId)) return rejectForward(409, { error: 'federation cycle rejected' });
|
|
711
|
+
forwardPort = selected.port;
|
|
712
|
+
}
|
|
713
|
+
const invalidateProof = () => verifier.invalidate(proofKey);
|
|
714
|
+
const actionCapture = isAskAction ? (status, text) => {
|
|
715
|
+
if (status < 200 || status >= 300) invalidateProof();
|
|
716
|
+
try { JSON.parse(text); } catch (_) { invalidateProof(); }
|
|
717
|
+
} : capture;
|
|
718
|
+
proxyHttp(req, res, { port: forwardPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture: actionCapture,
|
|
719
|
+
onForwardFailure: isAskAction ? invalidateProof : null, abortWithClient: isAskAction, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
|
|
626
720
|
};
|
|
627
721
|
}
|
|
628
722
|
|
|
@@ -1251,7 +1345,8 @@ async function probeReverseOwner(peer, fetchImpl = fetch) {
|
|
|
1251
1345
|
});
|
|
1252
1346
|
}
|
|
1253
1347
|
|
|
1254
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null }) {
|
|
1348
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null, askProofNow = null, slotVerifier = null }) {
|
|
1349
|
+
slotVerifier = slotVerifier || (askProofNow ? createAskSlotVerifier({ now: askProofNow }) : askSlotVerifier);
|
|
1255
1350
|
const r = express.Router();
|
|
1256
1351
|
r.use((req, res, next) => {
|
|
1257
1352
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
@@ -1532,13 +1627,18 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1532
1627
|
return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
|
|
1533
1628
|
}
|
|
1534
1629
|
});
|
|
1535
|
-
r.use('/route', (req, res) =>
|
|
1630
|
+
r.use('/route', (req, res, next) => {
|
|
1631
|
+
routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly, hopSecret, slotVerifier })(req, res).catch(next);
|
|
1632
|
+
});
|
|
1536
1633
|
return r;
|
|
1537
1634
|
}
|
|
1538
1635
|
|
|
1539
|
-
function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null, liveHostLedger = null }) {
|
|
1636
|
+
function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null, liveHostLedger = null, askProofNow = null, slotVerifier = null }) {
|
|
1637
|
+
slotVerifier = slotVerifier || (askProofNow ? createAskSlotVerifier({ now: askProofNow }) : askSlotVerifier);
|
|
1540
1638
|
const r = express.Router();
|
|
1541
|
-
r.use((req, res) =>
|
|
1639
|
+
r.use((req, res, next) => {
|
|
1640
|
+
routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret, liveHostLedger, slotVerifier })(req, res).catch(next);
|
|
1641
|
+
});
|
|
1542
1642
|
return r;
|
|
1543
1643
|
}
|
|
1544
1644
|
|
|
@@ -55,6 +55,7 @@ function classifyResource(resource, method = 'GET') {
|
|
|
55
55
|
// attempt id a UUID. The routes live UNDER /event-feed because they are the
|
|
56
56
|
// feed's own write-back path; the class is the same ask-action.
|
|
57
57
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return m === 'POST' ? 'ask-action' : null;
|
|
58
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/capability$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
|
|
58
59
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return m === 'DELETE' ? 'ask-action' : null;
|
|
59
60
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
|
|
60
61
|
|
package/lib/server.js
CHANGED
|
@@ -26,7 +26,7 @@ const { transcribe } = require('./voice/transcribe.js');
|
|
|
26
26
|
const { selectProvider } = require('./fleet/provider.js');
|
|
27
27
|
const { fleetRoutes } = require('./fleet/routes.js');
|
|
28
28
|
const { leaseRoutes } = require('./fleet/lease-routes.js');
|
|
29
|
-
const { cellsRoutes } = require('./cells/routes.js');
|
|
29
|
+
const { cellsRoutes, publicCells } = require('./cells/routes.js');
|
|
30
30
|
const { liveHostRoutes } = require('./live-host/routes.js');
|
|
31
31
|
const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
|
|
32
32
|
const { createLiveBridge } = require('./live-host/bridge.js');
|
|
@@ -85,6 +85,7 @@ const { createAskReceipts } = require('./notify/ask-receipts.js');
|
|
|
85
85
|
const { createAskAnswerService } = require('./notify/ask-answer-service.js');
|
|
86
86
|
const { createEventFeedAsksRoutes } = require('./notify/event-feed-asks-routes.js');
|
|
87
87
|
const { createAskRelay } = require('./notify/ask-relay.js');
|
|
88
|
+
const { createAskSlotVerifier } = require('./nodes/ask-slot-verifier.js');
|
|
88
89
|
const { readFederationEventsEnabled } = require('./config.js');
|
|
89
90
|
|
|
90
91
|
function sanitizeReplyLabel(raw) {
|
|
@@ -127,6 +128,7 @@ function wsBridgeDefaults(cfg = {}) {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
function createServer(opts = {}) {
|
|
131
|
+
const askReverseVerifier = createAskSlotVerifier();
|
|
130
132
|
const cfg = loadConfig(opts);
|
|
131
133
|
// UNA istanza per server: trattiene le attach federate quando il client cade,
|
|
132
134
|
// cosi' chi torna con la stessa identita' riprende dal buffer. I parametri
|
|
@@ -234,7 +236,27 @@ function createServer(opts = {}) {
|
|
|
234
236
|
let closureRetry = null;
|
|
235
237
|
const askAnswerService = createAskAnswerService({
|
|
236
238
|
asks: asksStore,
|
|
237
|
-
|
|
239
|
+
submit: opts.askSubmit || (async (session, text) => {
|
|
240
|
+
let cell;
|
|
241
|
+
try {
|
|
242
|
+
const fleet = await fleetP;
|
|
243
|
+
const statusFn = fleet && (typeof fleet.cellStatus === 'function' ? fleet.cellStatus : fleet.status);
|
|
244
|
+
const status = fleet && fleet.available === true && typeof statusFn === 'function' ? await statusFn.call(fleet) : null;
|
|
245
|
+
if (!status || status.available !== true || !Array.isArray(status.cells)) {
|
|
246
|
+
return { outcome: 'failed-pre-paste', submitted: false, reason: 'fleet-unavailable' };
|
|
247
|
+
}
|
|
248
|
+
cell = publicCells(status, apiNodeId()).find(entry => entry.tmuxSession === session);
|
|
249
|
+
} catch (_) { return { outcome: 'failed-pre-paste', submitted: false, reason: 'fleet-unavailable' }; }
|
|
250
|
+
if (!cell) {
|
|
251
|
+
if (!sessionExistsImpl(session)) return { outcome: 'failed-pre-paste', submitted: false, reason: 'session-unavailable' };
|
|
252
|
+
// Manual tmux sessions keep the existing paste-only answer contract.
|
|
253
|
+
const pasted = await pasteToSession(cfg.tmuxBin, session, text);
|
|
254
|
+
return { outcome: pasted ? 'pasted-not-submitted' : 'delivery-unknown', submitted: false };
|
|
255
|
+
}
|
|
256
|
+
if (!cell.active || !cell.engine) return { outcome: 'failed-pre-paste', submitted: false, reason: 'cell-unavailable' };
|
|
257
|
+
// Same submission as cell messages; operator draft in the composer may be submitted.
|
|
258
|
+
return submitToSession(cfg.tmuxBin, session, text, { engine: cell.engine });
|
|
259
|
+
}),
|
|
238
260
|
receipts: askReceipts,
|
|
239
261
|
// La chiusura esce da QUI: e' il punto in cui OGNI transizione autorevole
|
|
240
262
|
// passa — risposta e scarto, rotta locale e rotta federata. Emetterla dalle
|
|
@@ -920,7 +942,7 @@ function createServer(opts = {}) {
|
|
|
920
942
|
// abbia mai preso un ticket). Resta della PWA autenticata l'EMISSIONE: il POST
|
|
921
943
|
// .../panel/<cella>/ticket transita di sotto, col Bearer, come ogni altra /api.
|
|
922
944
|
const panelFederatoRouter = federation.localRouter({
|
|
923
|
-
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
945
|
+
nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
924
946
|
hopSecret: () => hopSecret,
|
|
925
947
|
});
|
|
926
948
|
api.use('/route', (req, res, next) => {
|
|
@@ -1139,7 +1161,10 @@ function createServer(opts = {}) {
|
|
|
1139
1161
|
});
|
|
1140
1162
|
const askPeerTargets = async () => {
|
|
1141
1163
|
const list = await federatedPeers();
|
|
1142
|
-
|
|
1164
|
+
const current = nodesStore.loadStoreStrict(nodesPath);
|
|
1165
|
+
return (Array.isArray(list) ? list : []).map(peer => ({ ...peer,
|
|
1166
|
+
firstHop: current && current.nodes.find(node => node.name === peer.route?.[0]),
|
|
1167
|
+
}));
|
|
1143
1168
|
};
|
|
1144
1169
|
// Riconciliazione del RICEVENTE: per ogni alias ancora aperto si chiede
|
|
1145
1170
|
// all'owner quali domande sono ancora aperte. Se l'alias non c'e' piu' la'
|
|
@@ -1252,15 +1277,6 @@ function createServer(opts = {}) {
|
|
|
1252
1277
|
federatedRate: createSpeakRateLimiter(),
|
|
1253
1278
|
answerService: askAnswerService,
|
|
1254
1279
|
receipts: askReceipts,
|
|
1255
|
-
// Elenco dei peer autorizzati per il fan-out di default della domanda
|
|
1256
|
-
// (nessun `target` → tutti i nodi dell'owner). Il
|
|
1257
|
-
// dispatcher resta a target esatto: l'enumerazione la fa la route.
|
|
1258
|
-
peerTargets: async () => {
|
|
1259
|
-
const list = await federatedPeers();
|
|
1260
|
-
return (Array.isArray(list) ? list : [])
|
|
1261
|
-
.map((p) => p && (p.nodeId || p.instanceId))
|
|
1262
|
-
.filter(Boolean);
|
|
1263
|
-
},
|
|
1264
1280
|
log: opts.log || console.log,
|
|
1265
1281
|
}));
|
|
1266
1282
|
// Event feed: the closed SSE/JSON surface, mounted inside the
|
|
@@ -1305,7 +1321,11 @@ function createServer(opts = {}) {
|
|
|
1305
1321
|
// resolves the owner from the authorized store and forwards with the peer's
|
|
1306
1322
|
// own pair token. Uncertain outcomes are parked, never retried silently.
|
|
1307
1323
|
const askRelay = createAskRelay({
|
|
1324
|
+
nodesPath, slotVerifier: askReverseVerifier,
|
|
1308
1325
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1326
|
+
peers: federatedPeers,
|
|
1327
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1328
|
+
localToken: () => tokenHolder.value,
|
|
1309
1329
|
log: opts.log || console.log,
|
|
1310
1330
|
});
|
|
1311
1331
|
// Il body va parsato QUI: il router `api` non monta un parser globale, ogni
|
|
@@ -1326,23 +1346,30 @@ function createServer(opts = {}) {
|
|
|
1326
1346
|
if (out.ok && typeof eventFeedClient.dismissConfirmed === 'function') {
|
|
1327
1347
|
eventFeedClient.dismissConfirmed(b.ownerId, b.askId);
|
|
1328
1348
|
}
|
|
1329
|
-
return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown' });
|
|
1349
|
+
return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { dismissed: true } : { error: out.error || 'unknown', reason: out.reason });
|
|
1330
1350
|
}
|
|
1331
1351
|
if (b.action === 'verify') {
|
|
1332
1352
|
const out = await askRelay.verifyStatus({ ownerId: b.ownerId, askId: b.askId, requestId: b.requestId });
|
|
1333
|
-
return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { state: out.state } : { error: out.error || 'unknown', reason: out.reason });
|
|
1353
|
+
return res.status(out.ok ? 200 : out.code || 500).json(out.ok ? { state: out.state, ownerId: out.ownerId, askId: out.askId, actor: out.actor, receipt: out.receipt } : { error: out.error || 'unknown', reason: out.reason });
|
|
1334
1354
|
}
|
|
1335
|
-
const out = await askRelay.relayAnswer({ ownerId: b.ownerId, askId: b.askId, text: b.text });
|
|
1355
|
+
const out = await askRelay.relayAnswer({ ownerId: b.ownerId, askId: b.askId, text: b.text, rid: b.requestId });
|
|
1336
1356
|
return res.status(out.ok ? 200 : out.code || 500).json({
|
|
1337
|
-
...(out.ok ? { status: out.status } : {}),
|
|
1357
|
+
...(out.ok ? { status: out.status, ownerId: out.ownerId, askId: out.askId, actor: out.actor, receipt: out.receipt } : {}),
|
|
1338
1358
|
requestId: out.requestId || b.requestId,
|
|
1339
|
-
...(out.uncertain ? { uncertain: true, reason: out.reason } : {}),
|
|
1359
|
+
...(out.uncertain ? { uncertain: true, reason: out.reason, originalRequestId: out.originalRequestId } : {}),
|
|
1340
1360
|
...(!out.ok && !out.uncertain ? { error: out.error, reason: out.reason } : {}),
|
|
1341
1361
|
});
|
|
1342
1362
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1343
1363
|
});
|
|
1344
1364
|
// Riconciliazione dopo refresh: gli esiti incerti noti al relay (solo
|
|
1345
1365
|
// contatori/ID, mai testo) per ricostruire lo stato «verifica» senza reinvii.
|
|
1366
|
+
api.get('/asks-relay/capability', async (req, res) => {
|
|
1367
|
+
const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : '';
|
|
1368
|
+
const askId = typeof req.query.askId === 'string' ? req.query.askId : '';
|
|
1369
|
+
if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(askId)) return res.status(400).json({ error: 'ownerId e askId richiesti' });
|
|
1370
|
+
try { res.json(await askRelay.replyCapability({ ownerId, askId })); }
|
|
1371
|
+
catch (_) { res.json({ ownerId, askId, canReply: false, status: 'unreachable' }); }
|
|
1372
|
+
});
|
|
1346
1373
|
api.get('/asks-relay/state', (_req, res) => {
|
|
1347
1374
|
try { res.json({ attempts: askRelay.state() }); }
|
|
1348
1375
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
@@ -1358,6 +1385,12 @@ function createServer(opts = {}) {
|
|
|
1358
1385
|
if (!bridgeReadonly()) pushRelay.considerImported(event);
|
|
1359
1386
|
},
|
|
1360
1387
|
};
|
|
1388
|
+
try { nodesStore.migrateAdminReception({ filePath: nodesPath, readonly: bridgeReadonly() }); }
|
|
1389
|
+
catch (error) {
|
|
1390
|
+
if (error?.code !== 'NODES_STORE_MISSING') {
|
|
1391
|
+
(opts.log || console.log)(`Admin reception migration skipped: ${error?.code || 'NODES_STORE_UNAVAILABLE'}`);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1361
1394
|
const eventFeedClient = createEventFeedClient({
|
|
1362
1395
|
loadStore: () => nodesStore.loadStore(nodesPath),
|
|
1363
1396
|
token: () => tokenHolder.value,
|
|
@@ -1640,7 +1673,7 @@ function createServer(opts = {}) {
|
|
|
1640
1673
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1641
1674
|
});
|
|
1642
1675
|
api.use('/route', federation.localRouter({
|
|
1643
|
-
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
1676
|
+
nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
1644
1677
|
hopSecret: () => hopSecret,
|
|
1645
1678
|
// A designation forwarded from here to a peer is recorded by the answer
|
|
1646
1679
|
// the peer gives, not by a local guess: see liveHostLedgerCapture.
|
|
@@ -1672,7 +1705,7 @@ function createServer(opts = {}) {
|
|
|
1672
1705
|
|
|
1673
1706
|
app.use('/api', api);
|
|
1674
1707
|
app.use('/federation', federation.peerRouter({
|
|
1675
|
-
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
1708
|
+
nodesPath, slotVerifier: askReverseVerifier, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
1676
1709
|
fetchImpl: healthFetch, hopSecret: () => hopSecret,
|
|
1677
1710
|
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
1678
1711
|
diagnostics,
|
package/lib/tmux/actions.js
CHANGED
|
@@ -118,8 +118,10 @@ function execTmux(execFileImpl, tmuxBin, args, timeout = 5000) {
|
|
|
118
118
|
// risolto prima del paste e verificato di nuovo prima dell'Enter, evitando
|
|
119
119
|
// prefix match e il riuso del solo nome sessione.
|
|
120
120
|
async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
121
|
+
let pasteAttempted = false;
|
|
122
|
+
const failed = reason => ({ submitted: false, outcome: pasteAttempted ? 'delivery-unknown' : 'failed-pre-paste', reason });
|
|
121
123
|
if (!isValidSession(session) || !submitTextOk(text)) {
|
|
122
|
-
return
|
|
124
|
+
return failed('input non valido');
|
|
123
125
|
}
|
|
124
126
|
const execFileImpl = opts.execFileImpl || execFile;
|
|
125
127
|
const fsImpl = opts.fsImpl || fs;
|
|
@@ -128,7 +130,7 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
|
128
130
|
? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
129
131
|
const codexComposer = /^(?:codex|codex-vl)(?:\.|$)/.test(String(opts.engine || ''));
|
|
130
132
|
const nonce = (opts.nonce || crypto.randomBytes(8).toString('hex')).replace(/[^a-f0-9]/g, '').slice(0, 32);
|
|
131
|
-
if (!nonce) return
|
|
133
|
+
if (!nonce) return failed('invio non disponibile');
|
|
132
134
|
const buffer = `ncmsg-${nonce}`;
|
|
133
135
|
const tmp = path.join(tmpRoot, `.nexuscrew-message-${nonce}.txt`);
|
|
134
136
|
let loaded = false;
|
|
@@ -139,14 +141,15 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
|
139
141
|
['display-message', '-p', '-t', `=${session}:`, '#{pane_id}']);
|
|
140
142
|
const paneId = pane.stdout.trim();
|
|
141
143
|
if (!pane.ok || !/^%[0-9]+$/.test(paneId)) {
|
|
142
|
-
return
|
|
144
|
+
return failed('sessione non raggiungibile');
|
|
143
145
|
}
|
|
144
146
|
const load = await execTmux(execFileImpl, tmuxBin, ['load-buffer', '-b', buffer, tmp]);
|
|
145
|
-
if (!load.ok) return
|
|
147
|
+
if (!load.ok) return failed('buffer non disponibile');
|
|
146
148
|
loaded = true;
|
|
149
|
+
pasteAttempted = true;
|
|
147
150
|
const paste = await execTmux(execFileImpl, tmuxBin,
|
|
148
151
|
['paste-buffer', '-p', '-t', paneId, '-b', buffer]);
|
|
149
|
-
if (!paste.ok) return
|
|
152
|
+
if (!paste.ok) return failed('consegna non riuscita');
|
|
150
153
|
const paneAlive = async () => {
|
|
151
154
|
const verify = await execTmux(execFileImpl, tmuxBin,
|
|
152
155
|
['display-message', '-p', '-t', paneId, '#{session_name}\t#{pane_dead}\t#{pane_id}']);
|
|
@@ -190,20 +193,20 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
|
190
193
|
// messaggio lungo conviene verificare che la cella si sia mossa.
|
|
191
194
|
const attesaBase = codexComposer ? 400 : 150;
|
|
192
195
|
await delay(attesaBase + Math.floor(text.length / 500) * 150);
|
|
193
|
-
if (!(await paneAlive())) return
|
|
196
|
+
if (!(await paneAlive())) return failed('sessione terminata durante la consegna');
|
|
194
197
|
if (codexComposer) {
|
|
195
198
|
const flush = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'C-e']);
|
|
196
|
-
if (!flush.ok) return
|
|
199
|
+
if (!flush.ok) return failed('testo consegnato ma flush composer non riuscito');
|
|
197
200
|
await delay(300);
|
|
198
|
-
if (!(await paneAlive())) return
|
|
201
|
+
if (!(await paneAlive())) return failed('sessione terminata durante la consegna');
|
|
199
202
|
}
|
|
200
203
|
const enter = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'Enter']);
|
|
201
|
-
if (!enter.ok) return
|
|
202
|
-
return { submitted: true, reason: codexComposer
|
|
204
|
+
if (!enter.ok) return failed('testo consegnato ma invio non riuscito');
|
|
205
|
+
return { submitted: true, outcome: 'submitted', reason: codexComposer
|
|
203
206
|
? 'bracketed paste + burst flush + Enter separati'
|
|
204
207
|
: 'bracketed paste + Enter separato' };
|
|
205
208
|
} catch (_) {
|
|
206
|
-
return
|
|
209
|
+
return failed('consegna non riuscita');
|
|
207
210
|
} finally {
|
|
208
211
|
if (loaded) await execTmux(execFileImpl, tmuxBin, ['delete-buffer', '-b', buffer]);
|
|
209
212
|
try { fsImpl.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
|