@mmmbuto/nexuscrew 0.9.65 → 0.9.66

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.
@@ -11,6 +11,8 @@
11
11
  // dies by construction. Transport failures back off with jitter 5→60 s; a
12
12
  // stream silent for 60 s is aborted and resynced.
13
13
 
14
+ const askCell = require('./ask-cell.js');
15
+
14
16
  const BACKOFF_MIN_MS = 5000;
15
17
  const BACKOFF_MAX_MS = 60000;
16
18
  const IDLE_TIMEOUT_MS = 60000;
@@ -259,6 +261,28 @@ function createEventFeedClient(opts = {}) {
259
261
  }
260
262
  }
261
263
 
264
+ // An ask of a snapshot, attributed to its owner, with the cell the OWNER
265
+ // resolved for it. `cellId` on the wire becomes `ownerCellId` here (validated by
266
+ // the schema check) and any `ownerCellId`/`originCell` the wire carried is
267
+ // dropped: those two names are ours, set only by authenticated ingress. When the
268
+ // snapshot has no cell for the ask, the cell the view already held for the SAME
269
+ // generation (same owner timestamp and content) is kept; a new generation never
270
+ // inherits it.
271
+ function withOwnerCell(wire, ownerId, knownCells) {
272
+ const { cellId, ownerCellId: _forged, originCell: _forgedOrigin, ...ask } = wire;
273
+ const out = { ...ask, ownerId };
274
+ if (askCell.isCellId(cellId)) return { ...out, ownerCellId: cellId };
275
+ const hit = knownCells.get(out.ownerAskId || out.id);
276
+ if (hit) {
277
+ const { askFingerprint } = require('./asks.js');
278
+ const priorTs = Object.hasOwn(hit.prior, 'ownerAskTs') ? hit.prior.ownerAskTs : hit.prior.ts;
279
+ const nextTs = Object.hasOwn(out, 'ownerAskTs') ? out.ownerAskTs : out.ts;
280
+ if (Number.isSafeInteger(priorTs) && priorTs > 0 && priorTs === nextTs
281
+ && askFingerprint(hit.prior) === askFingerprint(out)) return { ...out, ownerCellId: hit.cellId };
282
+ }
283
+ return out;
284
+ }
285
+
262
286
  // Apply a fetched snapshot ONLY if it belongs to the current generation:
263
287
  // an obsolete fetch must never overwrite a newer view (the snapshot
264
288
  // WINS over the previous view of the same owner).
@@ -268,10 +292,18 @@ function createEventFeedClient(opts = {}) {
268
292
  view.cursor = snap.cursor || null;
269
293
  // A snapshot taken before the owner processed a dismiss still carries the
270
294
  // ask: the tombstone is what keeps it from coming back.
295
+ // What this view already knows about the cell of each ask, taken BEFORE the
296
+ // view is replaced: an owner that does not publish the cell in its snapshot
297
+ // (older release) must not make a card forget what its live frame said.
298
+ const knownCells = new Map();
299
+ for (const prior of [...view.asks, ...(view.alertAsks ? view.alertAsks.values() : [])]) {
300
+ const resolved = askCell.resolveAskCell({ ownerCellId: prior.ownerCellId, originCell: prior.originCell });
301
+ if (resolved.cellId) knownCells.set(prior.ownerAskId || prior.id, { prior, cellId: resolved.cellId });
302
+ }
271
303
  view.asks = Array.isArray(snap.asks)
272
304
  ? snap.asks
273
305
  .filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch, a) && !isAskDismissed(ownerId, a))
274
- .map((a) => ({ ...a, ownerId }))
306
+ .map((a) => withOwnerCell(a, ownerId, knownCells))
275
307
  : [];
276
308
  view.alertAsks = new Map(); // fresh snapshot replaces all live-only alert sources
277
309
  // A snapshot taken before the owner processed the dismissal still carries
@@ -405,7 +437,10 @@ function createEventFeedClient(opts = {}) {
405
437
  if (historicalAsk && typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [historicalAsk]);
406
438
  } else if (f.type === 'ask') {
407
439
  const ask = { id: f.askId, question: f.question, options: f.options, session: f.session, ts: f.ts,
408
- ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId };
440
+ ownerAskTs: Number.isSafeInteger(f.askTs) && f.askTs > 0 ? f.askTs : null, ownerId: envelope.ownerId,
441
+ // The envelope of an ask frame is scoped to the cell the owner resolved;
442
+ // it is the owner's statement, carried on the authenticated stream.
443
+ ...(envelope.scope === 'cell' && askCell.isCellId(envelope.cellId) ? { ownerCellId: envelope.cellId } : {}) };
409
444
  if (!rememberLiveAsk(envelope.ownerId, ask)) return false;
410
445
  if (typeof opts.onAuthoritativeAsks === 'function') opts.onAuthoritativeAsks(envelope.ownerId, [ask]);
411
446
  opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
@@ -712,6 +747,9 @@ function createEventFeedClient(opts = {}) {
712
747
  && (a.question === undefined || typeof a.question === 'string')
713
748
  && (a.options === undefined || Array.isArray(a.options))
714
749
  && (a.session === undefined || typeof a.session === 'string')
750
+ // The cell the OWNER resolved for the ask: absent (an older owner, or a
751
+ // session the owner could not resolve) or a well-formed cell id, never anything else.
752
+ && (a.cellId === undefined || askCell.isCellId(a.cellId))
715
753
  && (a.ts === undefined || typeof a.ts === 'number');
716
754
  if (!snap.asks.every(askFieldsOk)) return schema('asks');
717
755
  return { ok: true };
@@ -14,6 +14,7 @@
14
14
  const express = require('express');
15
15
  const nodesStore = require('../nodes/store.js');
16
16
  const eventFeedAcl = require('./event-feed-acl.js');
17
+ const askCell = require('./ask-cell.js');
17
18
 
18
19
  const SNAPSHOT_MAX_BYTES = 3 * 1024 * 1024;
19
20
  const SNAPSHOT_MAX_ASKS = 100;
@@ -241,8 +242,13 @@ function createEventFeedRoutes(deps) {
241
242
  // guardia e' esplicita sui campi di provenienza, non dedotta dalla
242
243
  // visibilita' della cella.
243
244
  .filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
244
- .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
245
- .filter((a) => peer.allows({ scope: 'cell', cellId: cellForSession(a.session) }));
245
+ // The cell is resolved ONCE, by this owner, through its own definitions, and
246
+ // it travels with the ask: a session with a custom tmux name does not decode
247
+ // to a cell on the receiving side, and the receiver must not guess it. Only
248
+ // a well-formed id is published; an unresolved one is simply absent.
249
+ .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts, cell: cellForSession(a.session) }))
250
+ .filter((a) => peer.allows({ scope: 'cell', cellId: a.cell }))
251
+ .map(({ cell, ...ask }) => (askCell.isCellId(cell) ? { ...ask, cellId: cell } : ask));
246
252
  askSourceCount = visible.length;
247
253
  asks = visible.slice(0, SNAPSHOT_MAX_ASKS);
248
254
  } catch (e) {
@@ -113,8 +113,31 @@ function peerAllows(peer, otherId) {
113
113
  return Array.isArray(peer.selected) && peer.selected.includes(otherId);
114
114
  }
115
115
 
116
+ // A host-only peer (publishRemote, schema 4) is a REMOTE node this installation
117
+ // publishes to an explicit audience of its own admin nodes. The boundary is
118
+ // one-way and does not depend on the peer's own claims or grants:
119
+ // audience -> peer : yes, while the reader is in the audience AND still holds
120
+ // the operator grant (re-read on every call);
121
+ // peer -> anyone : never (not another peer, not a topology, not a route);
122
+ // everyone else : no, whatever `visibility` or `shared` say.
123
+ function isHostOnlyPeer(peer) {
124
+ return !!peer && peer.publishRemote === true;
125
+ }
126
+
127
+ function audienceAllows(reader, hostOnly) {
128
+ return Array.isArray(hostOnly.publishRemoteAudience)
129
+ && typeof reader.nodeId === 'string'
130
+ && hostOnly.publishRemoteAudience.includes(reader.nodeId)
131
+ && !isHostOnlyPeer(reader)
132
+ && allowedByClass('operator', reader);
133
+ }
134
+
116
135
  function canTransit(ingress, egress) {
117
136
  if (!ingress || !egress || ingress.name === egress.name) return !ingress;
137
+ // The ingress is the AUTHENTICATED peer record (looked up by its token), never
138
+ // something read from the query, a header or the visited chain.
139
+ if (isHostOnlyPeer(ingress)) return false;
140
+ if (isHostOnlyPeer(egress)) return audienceAllows(ingress, egress);
118
141
  // `shared` is the explicit publication gate. Visibility remains the hub ACL,
119
142
  // but it cannot make a private peer routable on its own.
120
143
  return egress.shared === true
@@ -274,6 +297,9 @@ function liveHostAllowedFor(ingress) {
274
297
  // WebSocket upgrade. A resource whose class is unknown, or whose grants the
275
298
  // owner has not conceded, is refused before anything is dispatched.
276
299
  function classGateCheck(resource, method, ingress) {
300
+ // A host-only peer reaches nothing of ours, whatever grants its record carries
301
+ // or gains: the zeroed vector is the first lock, this is the second.
302
+ if (isHostOnlyPeer(ingress)) return { ok: false, status: 403, reason: 'host-only-peer' };
277
303
  const cls = classifyResource(resource, method);
278
304
  if (!cls) return { ok: false, status: 404, reason: 'resource-not-classified' };
279
305
  if (!allowedByClass(cls, ingress)) return { ok: false, status: 403, reason: `grant-required:${cls}` };
@@ -1209,7 +1235,9 @@ async function collectTopologyDetailed({
1209
1235
  instanceId: n.nodeId, name: n.name, route: [n.name], direct: true,
1210
1236
  label: store.validLabel(n.label) ? n.label.trim() : '',
1211
1237
  });
1212
- if (ttl <= 1 || !n.token) continue;
1238
+ // A host-only peer is published as a LEAF: whatever it says about its own
1239
+ // children is never asked for, so it cannot announce nodes to our readers.
1240
+ if (ttl <= 1 || !n.token || isHostOnlyPeer(n)) continue;
1213
1241
  probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs: childBudget }) });
1214
1242
  }
1215
1243
  const results = await Promise.all(probes.map(async ({ n, pending }) => {
@@ -1286,7 +1314,9 @@ async function collectLocalTopology({
1286
1314
  const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs, childSnapshots: false });
1287
1315
  const st = store.loadStore(nodesPath);
1288
1316
  const directNames = new Set(((st && st.nodes) || [])
1289
- .filter((n) => n.direction !== 'inbound' || n.shared === true)
1317
+ // A host-only peer is a leaf: nothing cached "behind" it survives (entries
1318
+ // written before the mode was turned on included).
1319
+ .filter((n) => (n.direction !== 'inbound' || n.shared === true) && !isHostOnlyPeer(n))
1290
1320
  .map((n) => n.name));
1291
1321
  const authoritative = new Set(live.authoritative);
1292
1322
  const liveIds = new Set(live.nodes.map((n) => n.instanceId));
@@ -1362,6 +1392,15 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1362
1392
  if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
1363
1393
  req.peer = peer; next();
1364
1394
  });
1395
+ // A host-only peer (publish-remote) reaches NOTHING of ours over the protocol:
1396
+ // the only thing it may do is the authenticated health probe. Share, topology,
1397
+ // reverse-pool and routes are all refused here, before their handlers run.
1398
+ r.use((req, res, next) => {
1399
+ if (isHostOnlyPeer(req.peer) && !(req.method === 'GET' && req.path === '/health')) {
1400
+ return res.status(403).json({ error: 'non concesso a questo nodo', reason: 'host-only-peer' });
1401
+ }
1402
+ return next();
1403
+ });
1365
1404
  // Health federato: il peer autenticato (acceptToken matchato sopra) ottiene un
1366
1405
  // 200 esplicito con instanceId/version. Serve da target di probeHealth() lato
1367
1406
  // Initiator: distingue transport (porta aperta) da auth (200 vs 401) da
@@ -2072,9 +2111,9 @@ function acceptWsThenClose(socket, req, code, reason) {
2072
2111
 
2073
2112
 
2074
2113
  module.exports = {
2075
- isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
2114
+ isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, classGateCheck, routeHandler,
2076
2115
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
2077
- peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
2116
+ peerFromToken, peerAllows, canTransit, isHostOnlyPeer, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
2078
2117
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
2079
2118
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
2080
2119
  hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
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).
@@ -1519,15 +1520,32 @@ function createServer(opts = {}) {
1519
1520
  // Guards and alias mutation belong only to the selected generation.
1520
1521
  const alias = storedAlias && ask && asksStore.sameImportedGeneration(storedAlias, ask) ? storedAlias : null;
1521
1522
  const grants = peer && accessPresets.grantsOf(peer);
1522
- const cellId = ask ? cellIdFromTmuxSession(ask.session) : previous && previous.cellId;
1523
- const visible = !!((ask || previous) && cellId && grants && grants.configured && grants.grants.eventsAccess === true
1523
+ // The cell comes from what the OWNER resolved (feed or attested origin) and,
1524
+ // only for canonical names, from the session; see lib/notify/ask-cell.js. A
1525
+ // body field is never consulted: the dismissal request carries no cell.
1526
+ // The selected ask keeps its OWN cell. The other source (alias <-> feed card)
1527
+ // lends its cell only when it is bound to the same generation by an explicit,
1528
+ // equal owner timestamp and the same content: a generation that is unknown
1529
+ // on either side is not "the same" for a cell (sameImportedGeneration stays
1530
+ // permissive about it, because guards and alias mutation want that). If both
1531
+ // sources are authenticated and disagree there is no cell at all.
1532
+ const boundToSelected = item => item === ask || (tsOf(item) !== null && tsOf(item) === tsOf(ask)
1533
+ && asksStore.askFingerprint(item) === asksStore.askFingerprint(ask));
1534
+ const sameAsk = ask && [storedAlias, feedAsk].filter(item => item && boundToSelected(item));
1535
+ const cellId = ask ? askCell.resolveAskCellFrom(sameAsk).cellId : previous && previous.cellId;
1536
+ const grantsAllowFeed = !!(grants && grants.configured && grants.grants.eventsAccess === true);
1537
+ const visible = !!((ask || previous) && cellId && grantsAllowFeed
1524
1538
  && eventFeedAcl.visibilityAllows(grants.grants, cellId));
1539
+ // The ask is known and this node may read the owner's feed, but no
1540
+ // authenticated source tells which cell it belongs to (a card persisted by an
1541
+ // older release, an owner that does not publish the cell): said, not guessed.
1542
+ const cellUnresolved = !!(ask && !cellId && grantsAllowFeed);
1525
1543
  const blocked = !!((ask && ask.answered) || (alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
1526
1544
  || askRelay.state().some(attempt => attempt.ownerId === ownerId && attempt.askId === askId && ['sent', 'uncertain'].includes(attempt.state)));
1527
1545
  const slot = peer && peer.reversePool && peer.reversePool.slots && peer.reversePool.slots[peer.reversePool.activeSlot];
1528
1546
  const unavailable = !!(peer && peer.direction === 'inbound' && (!peer.shared || !slot || !Number.isInteger(slot.port)))
1529
1547
  || askRelay.channelState(ownerId).unavailable;
1530
- return { peer, alias, previous, ask, grants, cellId, visible, blocked: blocked || ambiguous, unavailable, ambiguous };
1548
+ return { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, blocked: blocked || ambiguous, unavailable, ambiguous };
1531
1549
  }
1532
1550
  api.post('/asks-relay', express.json({ limit: '8kb' }), async (req, res) => {
1533
1551
  try {
@@ -1541,8 +1559,13 @@ function createServer(opts = {}) {
1541
1559
  }
1542
1560
  try { if (!asksStore.health().readable) return res.status(503).json({ error: 'dismissal store unavailable' }); }
1543
1561
  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) return res.status(404).json({ error: 'unknown or invisible ask' });
1562
+ const { peer, alias, previous, ask, grants, cellId, visible, cellUnresolved, unavailable, ambiguous } = localDismissalEligibility(b.ownerId, b.askId);
1563
+ if (!visible) {
1564
+ if (cellUnresolved) {
1565
+ 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' });
1566
+ }
1567
+ return res.status(404).json({ error: 'unknown or invisible ask' });
1568
+ }
1546
1569
  if (ambiguous) return res.status(409).json({ error: 'ask generation is ambiguous; refresh the owner view', reason: 'generation-ambiguous' });
1547
1570
  if (ask && ask.answered) return res.status(409).json({ error: 'ask already answered', reason: 'answered' });
1548
1571
  if ((alias && (asksStore.isAnswering(alias.id) || askReceipts.isBlocked(alias.id)))
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.65",
3
+ "version": "0.9.66",
4
4
  "description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {