@mmmbuto/nexuscrew 0.9.65 → 0.9.67
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 +24 -0
- package/docs/NODES.md +55 -0
- package/docs/SECURITY.md +40 -0
- package/frontend/dist/assets/{index-DkYRC1vV.js → index-D__76yWR.js} +42 -42
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +14 -1
- package/lib/audio/routes.js +2 -2
- package/lib/cli/commands.js +31 -0
- package/lib/live-host/federation.js +10 -0
- package/lib/live-host/routes.js +12 -2
- package/lib/nodes/access-presets.js +27 -7
- package/lib/nodes/commands.js +63 -4
- package/lib/nodes/reverse-slot-listeners.js +4 -1
- package/lib/nodes/store.js +205 -12
- package/lib/notify/ask-cell.js +53 -0
- package/lib/notify/event-feed-client.js +40 -2
- package/lib/notify/event-feed-routes.js +8 -2
- package/lib/proxy/federation.js +308 -17
- package/lib/proxy/node-proxy.js +86 -3
- package/lib/server.js +126 -12
- package/lib/settings/routes.js +6 -0
- package/package.json +1 -1
package/lib/server.js
CHANGED
|
@@ -58,6 +58,7 @@ const { createCellScopeGuard } = require('./cells/scope-guard.js');
|
|
|
58
58
|
const { createCellScope } = require('./cells/scope.js');
|
|
59
59
|
const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
|
|
60
60
|
const { createOwnerAskScope } = require('./notify/owner-ask-scope.js');
|
|
61
|
+
const askCell = require('./notify/ask-cell.js');
|
|
61
62
|
// Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
|
|
62
63
|
// limitare il danno di un nodo che mente sulla propria cella (il tetto per
|
|
63
64
|
// target si calcola sul NODO, che la catena visited verifica).
|
|
@@ -500,7 +501,7 @@ function createServer(opts = {}) {
|
|
|
500
501
|
if (!st) return null;
|
|
501
502
|
const node = nodesStore.getNode(st, name);
|
|
502
503
|
if (!node) return null;
|
|
503
|
-
return { localPort: node.localPort, token: node.token || null };
|
|
504
|
+
return { localPort: node.localPort, token: node.token || null, hostOnly: node.publishRemote === true };
|
|
504
505
|
}
|
|
505
506
|
|
|
506
507
|
const runtimePort = () => (server && server.address() ? server.address().port : cfg.port);
|
|
@@ -993,6 +994,15 @@ function createServer(opts = {}) {
|
|
|
993
994
|
const resource = req.url.slice(i + 2);
|
|
994
995
|
if (!/^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(resource)) return next();
|
|
995
996
|
if (req.method === 'POST' && /^\/panel\/[A-Za-z0-9._-]{1,32}\/ticket\/?$/.test(resource)) return next();
|
|
997
|
+
// The unauthenticated door exists only for a browser frame, which carries no
|
|
998
|
+
// header and arrives on this machine's own listener. It never opens for: a
|
|
999
|
+
// request with ANY Authorization (the local token goes through requireToken like
|
|
1000
|
+
// every other /api call; a peer's token is not the owner's); a request that came
|
|
1001
|
+
// in on a peer-bound reverse listener; a route that names a host-only peer at
|
|
1002
|
+
// any hop. Those fall through to requireToken, whatever the first hop is.
|
|
1003
|
+
const onPeerListener = reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort);
|
|
1004
|
+
if (bearerFrom(req) || onPeerListener
|
|
1005
|
+
|| federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(req.url) })) return next();
|
|
996
1006
|
panelFederatoRouter(req, res, next);
|
|
997
1007
|
});
|
|
998
1008
|
api.use(requireToken(tokenStore));
|
|
@@ -1160,12 +1170,28 @@ function createServer(opts = {}) {
|
|
|
1160
1170
|
}
|
|
1161
1171
|
return out;
|
|
1162
1172
|
};
|
|
1163
|
-
|
|
1173
|
+
// A host-only peer is named only to the readers it is published to; a federated
|
|
1174
|
+
// reader is identified by the verified hop proof, never by the bare header.
|
|
1175
|
+
const hostOnlyNodesFor = (req) => {
|
|
1176
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
1177
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
1178
|
+
if (!st || vis.unreadable) return [];
|
|
1179
|
+
return st.nodes.filter((n) => n.publishRemote === true && !vis.hiddenNames.has(n.name)).map((n) => n.name);
|
|
1180
|
+
};
|
|
1181
|
+
const nodePanelPortsFor = (req) => {
|
|
1182
|
+
const out = nodePanelPorts();
|
|
1183
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
1184
|
+
for (const name of vis.hiddenNames) delete out[name];
|
|
1185
|
+
if (vis.unreadable) return {};
|
|
1186
|
+
return out;
|
|
1187
|
+
};
|
|
1188
|
+
api.get('/config', (req, res) => res.json({
|
|
1164
1189
|
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
1165
1190
|
bind: cfg.bind, port: cfg.port, panelPort: cfg.panelPort,
|
|
1166
1191
|
protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
|
|
1167
1192
|
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
1168
|
-
nodePanelPorts:
|
|
1193
|
+
nodePanelPorts: nodePanelPortsFor(req),
|
|
1194
|
+
hostOnlyNodes: hostOnlyNodesFor(req),
|
|
1169
1195
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
1170
1196
|
// i parametri del terminale sono configurabili dal server e leggibili
|
|
1171
1197
|
// dal client (che ne ha comunque i default iniettabili).
|
|
@@ -1519,15 +1545,32 @@ function createServer(opts = {}) {
|
|
|
1519
1545
|
// Guards and alias mutation belong only to the selected generation.
|
|
1520
1546
|
const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
|
|
1521
1547
|
const grants = peer && accessPresets.grantsOf(peer);
|
|
1522
|
-
|
|
1523
|
-
|
|
1548
|
+
// The cell comes from what the OWNER resolved (feed or attested origin) and,
|
|
1549
|
+
// only for canonical names, from the session; see lib/notify/ask-cell.js. A
|
|
1550
|
+
// body field is never consulted: the dismissal request carries no cell.
|
|
1551
|
+
// The selected ask keeps its OWN cell. The other source (alias <-> feed card)
|
|
1552
|
+
// lends its cell only when it is bound to the same generation by an explicit,
|
|
1553
|
+
// equal owner timestamp and the same content: a generation that is unknown
|
|
1554
|
+
// on either side is not "the same" for a cell (sameImportedGeneration stays
|
|
1555
|
+
// permissive about it, because guards and alias mutation want that). If both
|
|
1556
|
+
// sources are authenticated and disagree there is no cell at all.
|
|
1557
|
+
const boundToSelected = item => item === ask || (tsOf(item) !== null && tsOf(item) === tsOf(ask)
|
|
1558
|
+
&& asksStore.askFingerprint(item) === asksStore.askFingerprint(ask));
|
|
1559
|
+
const sameAsk = ask && [storedAlias, feedAsk].filter(item => item && boundToSelected(item));
|
|
1560
|
+
const cellId = ask ? askCell.resolveAskCellFrom(sameAsk).cellId : previous && previous.cellId;
|
|
1561
|
+
const grantsAllowFeed = !!(grants && grants.configured && grants.grants.eventsAccess === true);
|
|
1562
|
+
const visible = !!((ask || previous) && cellId && grantsAllowFeed
|
|
1524
1563
|
&& eventFeedAcl.visibilityAllows(grants.grants, cellId));
|
|
1564
|
+
// The ask is known and this node may read the owner's feed, but no
|
|
1565
|
+
// authenticated source tells which cell it belongs to (a card persisted by an
|
|
1566
|
+
// older release, an owner that does not publish the cell): said, not guessed.
|
|
1567
|
+
const cellUnresolved = !!(ask && !cellId && grantsAllowFeed);
|
|
1525
1568
|
const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
|
|
1526
1569
|
|| askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
|
|
1527
1570
|
const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
|
|
1528
1571
|
const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
|
|
1529
1572
|
|| askRelay.channelState(ownerId).unavailable;
|
|
1530
|
-
return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
|
|
1573
|
+
return { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, blocked: blocked || ambiguous, unavailable, ambiguous };
|
|
1531
1574
|
}
|
|
1532
1575
|
api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
|
|
1533
1576
|
try {
|
|
@@ -1541,8 +1584,13 @@ function createServer(opts = {}) {
|
|
|
1541
1584
|
}
|
|
1542
1585
|
try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
|
|
1543
1586
|
catch (_) { return res.status(503).json({ error: 'dismissal store unavailable' }); }
|
|
1544
|
-
const { peer, alias, previous, ask, grants, cellId, visible, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
|
|
1545
|
-
if (!visible)
|
|
1587
|
+
const { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
|
|
1588
|
+
if (!visible) {
|
|
1589
|
+
if (cellUnresolved) {
|
|
1590
|
+
return res.status(409).json({ error: 'the cell of this ask is not known from an authenticated owner source; refresh the owner view', reason: 'cell-unresolved' });
|
|
1591
|
+
}
|
|
1592
|
+
return res.status(404).json({ error: 'unknown or invisible ask' });
|
|
1593
|
+
}
|
|
1546
1594
|
if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
|
|
1547
1595
|
if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
|
|
1548
1596
|
if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
|
|
@@ -1890,6 +1938,9 @@ function createServer(opts = {}) {
|
|
|
1890
1938
|
},
|
|
1891
1939
|
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1892
1940
|
localToken: () => tokenHolder.value,
|
|
1941
|
+
// A federated request that terminates here is never re-sent to a host-only
|
|
1942
|
+
// peer (checked on the target and route actually resolved, store read now).
|
|
1943
|
+
federatedGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
|
|
1893
1944
|
}),
|
|
1894
1945
|
getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
|
|
1895
1946
|
}));
|
|
@@ -1948,7 +1999,15 @@ function createServer(opts = {}) {
|
|
|
1948
1999
|
peers: federatedPeers,
|
|
1949
2000
|
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
1950
2001
|
localToken: () => tokenHolder.value,
|
|
2002
|
+
// Never designate, query or bridge to a host-only peer, whatever the route.
|
|
2003
|
+
ownerGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
|
|
1951
2004
|
}),
|
|
2005
|
+
// A designation that already points at a host-only peer is not shown to a
|
|
2006
|
+
// federated reader outside its audience (it would name the peer).
|
|
2007
|
+
ownerHiddenFor: (req, ownerId) => {
|
|
2008
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2009
|
+
return vis.unreadable || vis.hiddenIds.has(ownerId);
|
|
2010
|
+
},
|
|
1952
2011
|
originResolver: createOriginResolver({ localNodeId: federatedNodeId, hopSecret: () => hopSecret }),
|
|
1953
2012
|
federatedRate: createSpeakRateLimiter(),
|
|
1954
2013
|
}));
|
|
@@ -1958,6 +2017,30 @@ function createServer(opts = {}) {
|
|
|
1958
2017
|
api.use('/vl-nodes', vlNodeApiRoutes({
|
|
1959
2018
|
storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
|
|
1960
2019
|
}));
|
|
2020
|
+
// A deck keeps references to nodes (tiles, floating windows): those that point at a host-only peer are not
|
|
2021
|
+
// served to a federated reader outside its audience, in any answer (the 409 carries the current deck too).
|
|
2022
|
+
api.use('/decks', (req, res, next) => {
|
|
2023
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2024
|
+
if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
|
|
2025
|
+
// A reader served a filtered deck must not be able to save it back and erase what it was not shown:
|
|
2026
|
+
// changing a deck that carries references it cannot see is refused (and says nothing about them).
|
|
2027
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
|
|
2028
|
+
const first = String(req.url || '').split('?')[0].split('/').filter(Boolean)[0];
|
|
2029
|
+
let target = null; try { target = first ? decodeURIComponent(first) : null; } catch (_) { target = ''; }
|
|
2030
|
+
if (vis.unreadable) return res.status(503).json({ error: 'non disponibile' });
|
|
2031
|
+
let hiddenContent = false;
|
|
2032
|
+
try {
|
|
2033
|
+
// creating a deck (no name in the path) overwrites nothing: only the deck being changed is looked at
|
|
2034
|
+
const st = target === null ? null : decksStore.loadStore(decksPath);
|
|
2035
|
+
const touched = (st ? st.decks : []).filter((d) => d.name === target);
|
|
2036
|
+
hiddenContent = touched.some((d) => JSON.stringify(federation.redactHostOnlyReferences(d, vis)) !== JSON.stringify(d));
|
|
2037
|
+
} catch (_) { return res.status(503).json({ error: 'non disponibile' }); }
|
|
2038
|
+
if (hiddenContent) return res.status(403).json({ error: 'deck non modificabile da questo nodo', reason: 'deck-has-hidden-content' });
|
|
2039
|
+
}
|
|
2040
|
+
const json = res.json.bind(res);
|
|
2041
|
+
res.json = (body) => json(vis.unreadable ? { error: 'non disponibile' } : federation.redactHostOnlyReferences(body, vis));
|
|
2042
|
+
return next();
|
|
2043
|
+
});
|
|
1961
2044
|
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
1962
2045
|
api.use('/prefs', prefsRoutes({ cfg, prefsPath }));
|
|
1963
2046
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
@@ -2044,9 +2127,37 @@ function createServer(opts = {}) {
|
|
|
2044
2127
|
audio: { adapter: audioAdapter, queue: audioQueue },
|
|
2045
2128
|
reverseSlots: { ensure: startRotatableReverse, close: stopRotatableReverse, verify: verifyRotatablePool },
|
|
2046
2129
|
}));
|
|
2130
|
+
// Records that name a host-only peer are served only to the readers it is
|
|
2131
|
+
// published to. The WHOLE record is inspected (name or id anywhere in it).
|
|
2132
|
+
api.use('/diagnostics', (req, res, next) => {
|
|
2133
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2134
|
+
if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
|
|
2135
|
+
const esc = (v) => String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2136
|
+
const names = [...vis.hiddenNames].map((n) => new RegExp(`(^|[^A-Za-z0-9_-])${esc(n)}([^A-Za-z0-9_-]|$)`));
|
|
2137
|
+
const ids = [...vis.hiddenIds];
|
|
2138
|
+
const mentions = (rec) => { const t = JSON.stringify(rec); return ids.some((id) => t.includes(id)) || names.some((re) => re.test(t)); };
|
|
2139
|
+
const json = res.json.bind(res);
|
|
2140
|
+
res.json = (body) => {
|
|
2141
|
+
if (vis.unreadable) return json({ error: 'non disponibile' });
|
|
2142
|
+
if (body && Array.isArray(body.records)) return json({ ...body, records: body.records.filter((r) => !mentions(r)) });
|
|
2143
|
+
return json(body);
|
|
2144
|
+
};
|
|
2145
|
+
return next();
|
|
2146
|
+
});
|
|
2047
2147
|
api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
|
|
2048
|
-
api.get('/topology', async (
|
|
2049
|
-
try {
|
|
2148
|
+
api.get('/topology', async (req, res) => {
|
|
2149
|
+
try {
|
|
2150
|
+
// What was hidden when the request began stays hidden: a publication that ends during the wait does not
|
|
2151
|
+
// hand its leaf to a reader that was not allowed to see it when it asked.
|
|
2152
|
+
const visBefore = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2153
|
+
const topo = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath });
|
|
2154
|
+
// Decided AFTER the fan-out wait and on the final result, cache and stale
|
|
2155
|
+
// entries included: the policy is read again now, not before the await.
|
|
2156
|
+
const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
|
|
2157
|
+
if (vis.unreadable || visBefore.unreadable) return res.json({ instanceId: topo && topo.instanceId, nodes: [] });
|
|
2158
|
+
const merged = { hiddenNames: new Set([...visBefore.hiddenNames, ...vis.hiddenNames]), hiddenIds: new Set([...visBefore.hiddenIds, ...vis.hiddenIds]) };
|
|
2159
|
+
res.json({ ...topo, nodes: federation.hideHostOnlyEntries(topo && topo.nodes, merged) });
|
|
2160
|
+
}
|
|
2050
2161
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
2051
2162
|
});
|
|
2052
2163
|
api.use('/route', federation.localRouter({
|
|
@@ -2185,7 +2296,10 @@ function createServer(opts = {}) {
|
|
|
2185
2296
|
// emesso): transita e decide il proprietario, gate panelAccess compreso
|
|
2186
2297
|
// — stessa forma del bypass HTTP sulle panel-resource federate.
|
|
2187
2298
|
const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
|
|
2188
|
-
&& /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''))
|
|
2299
|
+
&& /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''))
|
|
2300
|
+
&& !bearerFrom(req) && !u.searchParams.get('token')
|
|
2301
|
+
&& !(reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort))
|
|
2302
|
+
&& !federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(pathname.slice('/api/route'.length)) });
|
|
2189
2303
|
if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
|
|
2190
2304
|
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
|
|
2191
2305
|
return;
|
|
@@ -2273,7 +2387,7 @@ function createServer(opts = {}) {
|
|
|
2273
2387
|
|
|
2274
2388
|
return {
|
|
2275
2389
|
app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
|
|
2276
|
-
panelApp, panelServer, panelServerV6,
|
|
2390
|
+
panelApp, panelServer, panelServerV6, reverseSlotListeners,
|
|
2277
2391
|
// Test/observability seam: publish a normalized state event through the
|
|
2278
2392
|
// same per-peer projection the local producers use.
|
|
2279
2393
|
notify: { publishState: notifier.publishState },
|
package/lib/settings/routes.js
CHANGED
|
@@ -710,6 +710,12 @@ function settingsRoutes(deps = {}) {
|
|
|
710
710
|
if (!node) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
711
711
|
if (node.direction !== 'outbound') return send(res, 409, { error: 'Share si attiva sul dispositivo che possiede la connessione SSH' });
|
|
712
712
|
if (!node.token || !node.nodeId) return send(res, 409, { error: 'nodo non associato: ripeti il pairing' });
|
|
713
|
+
// A host-only peer (publish-remote) is published ONE WAY to our own admin
|
|
714
|
+
// nodes; Share would publish THIS node on it. Refuse before any SSH restart
|
|
715
|
+
// or reverse negotiation is attempted (turning Share off stays possible).
|
|
716
|
+
if (node.publishRemote === true && body.shared) {
|
|
717
|
+
return send(res, 409, { error: 'publish-remote attivo su questo peer: Share resta spento (spegni prima publish-remote)', reason: 'publish-remote-active' });
|
|
718
|
+
}
|
|
713
719
|
if (body.shared && !nodesStore.isPort(node.reversePort)) {
|
|
714
720
|
return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
|
|
715
721
|
}
|
package/package.json
CHANGED