@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.
@@ -3,6 +3,8 @@
3
3
  const { createAskSlotVerifier, askSlotVerifier, binding: askSlotBinding } = require('../nodes/ask-slot-verifier.js');
4
4
  const http = require('node:http');
5
5
  const net = require('node:net');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
6
8
  const crypto = require('node:crypto');
7
9
  const express = require('express');
8
10
  const { WebSocketServer } = require('ws');
@@ -15,9 +17,9 @@ const { bearerFrom } = require('../auth/middleware.js');
15
17
  const { safeEqual } = require('../nodes/peering.js');
16
18
  const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
17
19
  const {
18
- sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
20
+ sanitizeRequestHeaders, sanitizeResponseHeaders, sanitizeHostOnlyResponseHeaders, hostOnlyHeadFilter, stripLocalTokenQuery,
19
21
  } = require('./node-proxy.js');
20
- const { signHop, HOP_HEADER } = require('./hop-proof.js');
22
+ const { signHop, verifyHop, HOP_HEADER } = require('./hop-proof.js');
21
23
  const { COOKIE_NAME: PANEL_COOKIE_NAME } = require('./panel-auth.js');
22
24
 
23
25
  const MAX_HOPS = 4;
@@ -113,14 +115,206 @@ function peerAllows(peer, otherId) {
113
115
  return Array.isArray(peer.selected) && peer.selected.includes(otherId);
114
116
  }
115
117
 
118
+ // A host-only peer (publishRemote, schema 4) is a REMOTE node this installation
119
+ // publishes to an explicit audience of its own admin nodes. The boundary is
120
+ // one-way and does not depend on the peer's own claims or grants:
121
+ // audience -> peer : yes, while the reader is in the audience AND still holds
122
+ // the operator grant (re-read on every call);
123
+ // peer -> anyone : never (not another peer, not a topology, not a route);
124
+ // everyone else : no, whatever `visibility` or `shared` say.
125
+ function isHostOnlyPeer(peer) {
126
+ return !!peer && peer.publishRemote === true;
127
+ }
128
+
129
+ function audienceAllows(reader, hostOnly) {
130
+ return Array.isArray(hostOnly.publishRemoteAudience)
131
+ && typeof reader.nodeId === 'string'
132
+ && hostOnly.publishRemoteAudience.includes(reader.nodeId)
133
+ && !isHostOnlyPeer(reader)
134
+ && allowedByClass('operator', reader);
135
+ }
136
+
116
137
  function canTransit(ingress, egress) {
117
138
  if (!ingress || !egress || ingress.name === egress.name) return !ingress;
139
+ // The ingress is the AUTHENTICATED peer record (looked up by its token), never
140
+ // something read from the query, a header or the visited chain.
141
+ if (isHostOnlyPeer(ingress)) return false;
142
+ if (isHostOnlyPeer(egress)) return audienceAllows(ingress, egress);
118
143
  // `shared` is the explicit publication gate. Visibility remains the hub ACL,
119
144
  // but it cannot make a private peer routable on its own.
120
145
  return egress.shared === true
121
146
  && peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
122
147
  }
123
148
 
149
+ // The audience decision for a CHAIN, not only for the last hop. A host-only peer
150
+ // is published to its audience: every node that took part in the request (the
151
+ // ones in `visited`, this node excluded) must be an audience member that still
152
+ // holds the operator grant. An id this node does not know is refused — the chain
153
+ // is what the peers declared, so only what the local store can vouch for counts.
154
+ function chainInAudience(st, chain, egress) {
155
+ if (!isHostOnlyPeer(egress)) return true;
156
+ if (!st || !Array.isArray(st.nodes) || !Array.isArray(chain)) return false;
157
+ for (const id of chain) {
158
+ if (id === st.nodeId) continue;
159
+ const reader = st.nodes.find((n) => n && n.nodeId === id);
160
+ if (!reader || !audienceAllows(reader, egress)) return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ // Does this target, or any hop of the route to it, resolve to a host-only peer
166
+ // of THIS node? Read from the store at the time of the call, never from a cache.
167
+ function touchesHostOnly(nodesPath, { target = null, route = [] } = {}) {
168
+ const st = store.loadStore(nodesPath);
169
+ if (!st) return true; // fail closed: an unreadable store vouches for nothing
170
+ const names = Array.isArray(route) ? route : [];
171
+ return st.nodes.some((n) => isHostOnlyPeer(n) && ((target && n.nodeId === target) || names.includes(n.name)));
172
+ }
173
+
174
+ // The first segments of `/<hop>/<hop>/_/...` (what is left after the /route mount).
175
+ function routeNamesOf(rest) {
176
+ const parts = String(rest || '').split('?')[0].split('/').filter(Boolean);
177
+ const idx = parts.indexOf(ROUTE_DELIMITER);
178
+ return idx < 0 ? [] : parts.slice(0, idx);
179
+ }
180
+
181
+ // The identity of the caller of a LOCAL API handler that is the last hop of a
182
+ // federated route. It exists only when the hop header verifies (HMAC over method,
183
+ // path and chain, the same proof audio/origin.js reads); the bare presence of the
184
+ // header proves nothing. Returns the verified chain, or null.
185
+ function verifiedHopChain(req, st, hopSecret) {
186
+ const headers = (req && req.headers) || {};
187
+ const proof = headers[HOP_HEADER];
188
+ const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
189
+ const visited = String(headers['x-nexuscrew-visited'] || '').split(',').filter(Boolean);
190
+ if (!secret || !st || typeof proof !== 'string' || visited.length < 2 || visited.length > MAX_HOPS + 1) return null;
191
+ if (visited.some((id) => !store.NODE_ID_RE.test(id)) || new Set(visited).size !== visited.length) return null;
192
+ if (visited.at(-1) !== st.nodeId) return null;
193
+ if (!verifyHop(secret, { method: req.method || 'GET', path: req.originalUrl || req.url, visited }, proof)) return null;
194
+ return visited;
195
+ }
196
+
197
+ // Which host-only peers a local API caller must NOT learn about. The owner (a
198
+ // request without the hop header) sees everything; a federated caller sees only
199
+ // the peers whose audience contains its whole verified chain; any header that
200
+ // does not verify, or an unknown id, hides all of them. Read fresh each time.
201
+ function hostOnlyVisibility(nodesPath, req, hopSecret) {
202
+ const hiddenNames = new Set(); const hiddenIds = new Set();
203
+ const federated = Boolean(req && req.headers && req.headers[HOP_HEADER] !== undefined);
204
+ // The owner (no hop header) is never filtered, whatever the state of the store.
205
+ if (!federated) return { hiddenNames, hiddenIds, unreadable: false };
206
+ const st = store.loadStore(nodesPath);
207
+ if (!st) return { hiddenNames, hiddenIds, unreadable: true };
208
+ const chain = verifiedHopChain(req, st, hopSecret);
209
+ for (const n of st.nodes) {
210
+ if (!isHostOnlyPeer(n)) continue;
211
+ const visible = chain !== null && chainInAudience(st, chain, n);
212
+ if (!visible) { hiddenNames.add(n.name); if (n.nodeId) hiddenIds.add(n.nodeId); }
213
+ }
214
+ return { hiddenNames, hiddenIds, unreadable: false };
215
+ }
216
+
217
+ // Topology entries of hidden host-only peers, and anything announced behind one.
218
+ function hideHostOnlyEntries(nodes, vis) {
219
+ if (!Array.isArray(nodes) || (!vis.hiddenNames.size && !vis.hiddenIds.size)) return nodes;
220
+ return nodes.filter((e) => !vis.hiddenIds.has(e && e.instanceId)
221
+ && !(e && Array.isArray(e.route) && e.route.some((name) => vis.hiddenNames.has(name))));
222
+ }
223
+
224
+ // Removes, from any JSON value, the objects that point at a hidden host-only peer: by route name (`node`, a path of
225
+ // names, or `route`), or by instance id (`ownerId`, `nodeId`, `instanceId`). Used on the answers of handlers that
226
+ // store references to nodes (decks).
227
+ function redactHostOnlyReferences(value, vis) {
228
+ if (!vis || (!vis.hiddenNames.size && !vis.hiddenIds.size)) return value;
229
+ const points = (o) => o && typeof o === 'object' && !Array.isArray(o) && (
230
+ (typeof o.node === 'string' && o.node.split('/').some((n) => vis.hiddenNames.has(n)))
231
+ || ['ownerId', 'nodeId', 'instanceId'].some((k) => typeof o[k] === 'string' && vis.hiddenIds.has(o[k]))
232
+ || (Array.isArray(o.route) && o.route.some((n) => vis.hiddenNames.has(n))));
233
+ const walk = (v) => {
234
+ if (Array.isArray(v)) return v.filter((e) => !points(e)).map(walk);
235
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
236
+ return v;
237
+ };
238
+ return walk(value);
239
+ }
240
+
241
+ // Streams (SSE, WebSocket) opened towards a host-only peer for a peer caller:
242
+ // the authorization is re-read about every second and a stream that no longer
243
+ // passes (audience, operator grant, mode off, peer or reader removed, store
244
+ // unreadable) is closed. Owner streams are not tracked.
245
+ const HOST_ONLY_RECHECK_MS = 1000;
246
+ const hostOnlyStreams = new Set();
247
+ let hostOnlyTimer = null;
248
+ const credentialFingerprint = (v) => crypto.createHash('sha256').update(String(v || '')).digest('hex');
249
+ // The generation of the publication a stream was opened under. Every opt-in takes its
250
+ // own full backup next to the store (never overwritten), so the SET of those backups
251
+ // changes whenever the mode is turned on again — even with the same audience, even
252
+ // within the same second (names are not ordered in time, so the whole set is read, not
253
+ // the "newest"). A removed backup changes it too. Unreadable directory: a value that
254
+ // never matches (fail closed).
255
+ function publicationGeneration(nodesPath) {
256
+ try {
257
+ const base = `${path.basename(nodesPath)}.pre-publish-remote-`;
258
+ const names = fs.readdirSync(path.dirname(nodesPath)).filter((n) => n.startsWith(base) && n.endsWith('.bak')).sort();
259
+ return crypto.createHash('sha256').update(names.join('\n')).digest('hex');
260
+ } catch (_) { return `unreadable:${Date.now()}:${Math.random()}`; }
261
+ }
262
+ const audienceFingerprint = (egress) => crypto.createHash('sha256').update(JSON.stringify([...(egress.publishRemoteAudience || [])].sort())).digest('hex');
263
+ function hostOnlyGuardFor(nodesPath, ingress, egress, visited) {
264
+ return {
265
+ nodesPath, ingressNodeId: ingress.nodeId, ingressFp: credentialFingerprint(ingress.acceptToken),
266
+ egressName: egress.name, egressNodeId: egress.nodeId, egressPort: egress.localPort,
267
+ egressFp: credentialFingerprint(egress.token),
268
+ audienceFp: audienceFingerprint(egress), generation: publicationGeneration(nodesPath), visited,
269
+ };
270
+ }
271
+ // A stream opened through a peer that was NOT host-only keeps the behaviour it always had, with one exception: when
272
+ // the publication is turned ON for that peer, every federated stream in flight through it ends.
273
+ function plainStreamGuardFor(nodesPath, egress) {
274
+ return { plain: true, nodesPath, egressName: egress.name, egressNodeId: egress.nodeId };
275
+ }
276
+ function streamGuardFor(nodesPath, ingress, egress, visited) {
277
+ return isHostOnlyPeer(egress) ? hostOnlyGuardFor(nodesPath, ingress, egress, visited) : plainStreamGuardFor(nodesPath, egress);
278
+ }
279
+ function hostOnlyStreamAllowed(guard, loaded) {
280
+ const st = loaded === undefined ? store.loadStore(guard.nodesPath) : loaded;
281
+ if (guard.plain) {
282
+ if (!st) return true; // a plain stream is not judged on an unreadable store
283
+ return !st.nodes.some((n) => n && n.nodeId === guard.egressNodeId && isHostOnlyPeer(n));
284
+ }
285
+ if (!st) return false;
286
+ const egress = store.getNode(st, guard.egressName);
287
+ const reader = st.nodes.find((n) => n && n.nodeId === guard.ingressNodeId);
288
+ if (!egress || !reader || !egress.token || !canTransit(reader, egress)) return false;
289
+ // The stream was opened under a host-only publication: it lives only while THAT publication
290
+ // is what authorizes it. Legacy sharing (shared=true after an off) is a different authorization.
291
+ if (!isHostOnlyPeer(egress)) return false;
292
+ if (audienceFingerprint(egress) !== guard.audienceFp || publicationGeneration(guard.nodesPath) !== guard.generation) return false;
293
+ // Same identities and credentials that were authenticated when the stream opened.
294
+ if (credentialFingerprint(reader.acceptToken) !== guard.ingressFp || credentialFingerprint(egress.token) !== guard.egressFp
295
+ || egress.nodeId !== guard.egressNodeId || egress.localPort !== guard.egressPort) return false;
296
+ return chainInAudience(st, guard.visited, egress);
297
+ }
298
+ function trackHostOnlyStream(guard, close) {
299
+ if (!guard) return () => {};
300
+ const entry = { guard, close };
301
+ hostOnlyStreams.add(entry);
302
+ if (!hostOnlyTimer) {
303
+ hostOnlyTimer = setInterval(() => {
304
+ const loaded = new Map(); // the store is read once per file per tick
305
+ const load = (p) => { if (!loaded.has(p)) { let v = null; try { v = store.loadStore(p); } catch (_) { v = null; } loaded.set(p, v); } return loaded.get(p); };
306
+ for (const e of [...hostOnlyStreams]) {
307
+ let ok = false;
308
+ try { ok = hostOnlyStreamAllowed(e.guard, load(e.guard.nodesPath)); } catch (_) { ok = e.guard.plain === true; }
309
+ if (!ok) { hostOnlyStreams.delete(e); try { e.close(); } catch (_) { /* gone */ } }
310
+ }
311
+ if (!hostOnlyStreams.size) { clearInterval(hostOnlyTimer); hostOnlyTimer = null; }
312
+ }, HOST_ONLY_RECHECK_MS);
313
+ if (typeof hostOnlyTimer.unref === 'function') hostOnlyTimer.unref();
314
+ }
315
+ return () => { hostOnlyStreams.delete(entry); };
316
+ }
317
+
124
318
  function parseRoute(raw) {
125
319
  const parts = String(raw || '').split('?')[0].split('/').filter(Boolean);
126
320
  const idx = parts.indexOf(ROUTE_DELIMITER);
@@ -274,6 +468,9 @@ function liveHostAllowedFor(ingress) {
274
468
  // WebSocket upgrade. A resource whose class is unknown, or whose grants the
275
469
  // owner has not conceded, is refused before anything is dispatched.
276
470
  function classGateCheck(resource, method, ingress) {
471
+ // A host-only peer reaches nothing of ours, whatever grants its record carries
472
+ // or gains: the zeroed vector is the first lock, this is the second.
473
+ if (isHostOnlyPeer(ingress)) return { ok: false, status: 403, reason: 'host-only-peer' };
277
474
  const cls = classifyResource(resource, method);
278
475
  if (!cls) return { ok: false, status: 404, reason: 'resource-not-classified' };
279
476
  if (!allowedByClass(cls, ingress)) return { ok: false, status: 403, reason: `grant-required:${cls}` };
@@ -489,7 +686,7 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
489
686
  const SSE_IDLE_TIMEOUT_MS = 90000;
490
687
  const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
491
688
 
492
- 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 }) {
689
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, hostOnlyEgress = false, streamGuard = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS, onForwardFailure = null, abortWithClient = false }) {
493
690
  // A body a local middleware has already read is no longer in the stream: the
494
691
  // headers would announce bytes that nothing sends, and the peer would wait for
495
692
  // a body that never arrives. Say it at once, with a name, instead of letting
@@ -507,8 +704,23 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
507
704
  if (r.statusCode < 200 || r.statusCode >= 300) failed();
508
705
  r.once('aborted', failed); r.once('error', failed);
509
706
  const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
707
+ const cleanResponse = hostOnlyEgress ? sanitizeHostOnlyResponseHeaders : sanitizeResponseHeaders;
708
+ if (hostOnlyEgress && r.statusCode >= 300 && r.statusCode < 400) {
709
+ // A redirect from a host-only peer would be followed by the browser WITH the owner's Authorization,
710
+ // towards any route of this node or of a trusted peer: it is never passed along.
711
+ r.resume();
712
+ return res.status(502).json({ error: 'redirect refused', reason: 'host-only-redirect' });
713
+ }
714
+ // host-only: every answer; a plain peer: only the long-lived (event streams)
715
+ if (streamGuard && (!streamGuard.plain || SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || '')))) {
716
+ const untrack = trackHostOnlyStream(streamGuard, () => {
717
+ try { up.destroy(); } catch (_) { /* gone */ }
718
+ try { res.destroy(); } catch (_) { /* gone */ }
719
+ });
720
+ res.once('close', untrack); up.once('close', untrack);
721
+ }
510
722
  if (!capture) {
511
- res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
723
+ res.writeHead(r.statusCode, cleanResponse(headers));
512
724
  if (SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || ''))) {
513
725
  // The response is open: no inactivity timeout any more (the 504 below
514
726
  // still covers a peer that never answers), and the watchdog is rearmed
@@ -542,7 +754,7 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
542
754
  const body = Buffer.concat(chunks);
543
755
  try { capture(r.statusCode, body.toString('utf8')); } catch (_) { /* the ledger never breaks the forward */ }
544
756
  if (res.writableEnded) return;
545
- res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
757
+ res.writeHead(r.statusCode, cleanResponse(headers));
546
758
  res.end(body);
547
759
  });
548
760
  });
@@ -642,7 +854,16 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
642
854
  }
643
855
  const next = st && store.getNode(st, parsed.route[0]);
644
856
  const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
645
- if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return rejectAskRequest(403, { error: 'route non consentita' });
857
+ // A host-only peer has no panel and no Live host to offer: refused for the
858
+ // owner as well, and before any other decision.
859
+ if (next && isHostOnlyPeer(next) && (isPanelResource(parsed.resource) || isLiveHostResource(parsed.resource))) {
860
+ return res.status(403).json({
861
+ error: 'risorsa non disponibile verso un nodo solo-host',
862
+ reason: isPanelResource(parsed.resource) ? 'host-only-no-panel' : 'host-only-no-live-host',
863
+ });
864
+ }
865
+ if (!next || !next.token || privateInbound
866
+ || (ingress && (!canTransit(ingress, next) || !chainInAudience(st, visited, next)))) return rejectAskRequest(403, { error: 'route non consentita' });
646
867
  if (parsed.resource === '/asks' && req.method === 'POST') {
647
868
  const view = accessPresets.grantsOf(next);
648
869
  if (!view.configured || view.label !== 'admin') {
@@ -713,7 +934,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
713
934
  if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) {
714
935
  return rejectForward(403, { error: 'READONLY: federated mutation blocked' });
715
936
  }
716
- if (!currentNext.token || currentNext.shared !== true || (currentIngress && !canTransit(currentIngress, currentNext))) {
937
+ if (!currentNext.token || currentNext.shared !== true || (currentIngress && (!canTransit(currentIngress, currentNext) || !chainInAudience(current, visited, currentNext)))) {
717
938
  return rejectForward(403, { error: 'route non consentita' });
718
939
  }
719
940
  if (!controlledVisited(req, currentIngress, current.nodeId)) return rejectForward(409, { error: 'federation cycle rejected' });
@@ -725,6 +946,8 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
725
946
  try { JSON.parse(text); } catch (_) { invalidateProof(); }
726
947
  } : capture;
727
948
  proxyHttp(req, res, { port: forwardPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture: actionCapture,
949
+ hostOnlyEgress: isHostOnlyPeer(next),
950
+ streamGuard: ingress ? streamGuardFor(nodesPath, ingress, next, visited) : null,
728
951
  onForwardFailure: isAskAction ? invalidateProof : null, abortWithClient: isAskAction, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
729
952
  };
730
953
  }
@@ -1200,7 +1423,7 @@ async function collectTopologyDetailed({
1200
1423
  // clients become part of Hydra only after their explicit Share toggle.
1201
1424
  if (!n.nodeId || seen.has(n.nodeId)
1202
1425
  || (!ingress && n.direction === 'inbound' && n.shared !== true)
1203
- || (ingress && !canTransit(ingress, n))) continue;
1426
+ || (ingress && (!canTransit(ingress, n) || !chainInAudience(st, [...seen, ingress.nodeId], n)))) continue;
1204
1427
  // `name` e' lo slug locale con cui si indirizza; `label` e' il nome
1205
1428
  // leggibile. Senza quest'ultimo un nodo raggiunto in transito arriva agli
1206
1429
  // altri come uno slug scelto da qualcun altro, e cinque installazioni
@@ -1209,7 +1432,9 @@ async function collectTopologyDetailed({
1209
1432
  instanceId: n.nodeId, name: n.name, route: [n.name], direct: true,
1210
1433
  label: store.validLabel(n.label) ? n.label.trim() : '',
1211
1434
  });
1212
- if (ttl <= 1 || !n.token) continue;
1435
+ // A host-only peer is published as a LEAF: whatever it says about its own
1436
+ // children is never asked for, so it cannot announce nodes to our readers.
1437
+ if (ttl <= 1 || !n.token || isHostOnlyPeer(n)) continue;
1213
1438
  probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs: childBudget }) });
1214
1439
  }
1215
1440
  const results = await Promise.all(probes.map(async ({ n, pending }) => {
@@ -1261,12 +1486,38 @@ async function collectTopologyDetailed({
1261
1486
  });
1262
1487
  }
1263
1488
  }
1264
- const ids = new Set(); const routes = new Set(); const unique = [];
1489
+ const ids = new Set(); const routes = new Set(); let unique = [];
1265
1490
  for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
1266
1491
  const routeKey = n.route.join('/');
1267
1492
  if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
1268
1493
  ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
1269
1494
  }
1495
+ if (ingress) {
1496
+ // The fan-out above waited: whatever authorized the reader at the start is read AGAIN now, before the
1497
+ // answer leaves. A host-only peer stays only if the SAME reader (same credential) still passes.
1498
+ const now = store.loadStore(nodesPath);
1499
+ const reader = now && now.nodes.find((n) => n && n.nodeId === ingress.nodeId
1500
+ && credentialFingerprint(n.acceptToken) === credentialFingerprint(ingress.acceptToken));
1501
+ const chain = [...seen, ingress.nodeId];
1502
+ // A peer is judged by the FULL rule NOW if it was host-only when the walk started or is host-only now: one that
1503
+ // stopped being host-only meanwhile (the publication ended during the wait) does not keep its leaf, and neither
1504
+ // does one whose mode, audience or chain no longer passes. Unreadable store: nothing is vouched for.
1505
+ const wasHostOnly = new Map(st.nodes.filter((n) => isHostOnlyPeer(n)).map((n) => [n.nodeId, audienceFingerprint(n)]));
1506
+ const candidates = new Map();
1507
+ for (const n of st.nodes) if (isHostOnlyPeer(n)) candidates.set(n.nodeId, n);
1508
+ for (const n of (now ? now.nodes : [])) if (isHostOnlyPeer(n)) candidates.set(n.nodeId, n);
1509
+ const closed = [...candidates.values()].filter((n) => {
1510
+ if (!now || !reader) return true;
1511
+ const cur = now.nodes.find((x) => x.nodeId === n.nodeId);
1512
+ if (!cur || !isHostOnlyPeer(cur)) return true; // the publication ended (or the peer is gone)
1513
+ if (wasHostOnly.has(n.nodeId) && wasHostOnly.get(n.nodeId) !== audienceFingerprint(cur)) return true; // another audience
1514
+ return !(canTransit(reader, cur) && chainInAudience(now, chain, cur));
1515
+ });
1516
+ if (closed.length) {
1517
+ const closedIds = new Set(closed.map((n) => n.nodeId)); const closedNames = new Set(closed.map((n) => n.name));
1518
+ unique = unique.filter((e) => !closedIds.has(e.instanceId) && !(e.direct && closedNames.has(e.name)) && !e.route.some((name, i) => i === 0 && closedNames.has(name)));
1519
+ }
1520
+ }
1270
1521
  return { instanceId: st.nodeId, nodes: unique, authoritative };
1271
1522
  }
1272
1523
 
@@ -1286,7 +1537,9 @@ async function collectLocalTopology({
1286
1537
  const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs, childSnapshots: false });
1287
1538
  const st = store.loadStore(nodesPath);
1288
1539
  const directNames = new Set(((st && st.nodes) || [])
1289
- .filter((n) => n.direction !== 'inbound' || n.shared === true)
1540
+ // A host-only peer is a leaf: nothing cached "behind" it survives (entries
1541
+ // written before the mode was turned on included).
1542
+ .filter((n) => (n.direction !== 'inbound' || n.shared === true) && !isHostOnlyPeer(n))
1290
1543
  .map((n) => n.name));
1291
1544
  const authoritative = new Set(live.authoritative);
1292
1545
  const liveIds = new Set(live.nodes.map((n) => n.instanceId));
@@ -1362,6 +1615,15 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1362
1615
  if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
1363
1616
  req.peer = peer; next();
1364
1617
  });
1618
+ // A host-only peer (publish-remote) reaches NOTHING of ours over the protocol:
1619
+ // the only thing it may do is the authenticated health probe. Share, topology,
1620
+ // reverse-pool and routes are all refused here, before their handlers run.
1621
+ r.use((req, res, next) => {
1622
+ if (isHostOnlyPeer(req.peer) && !(req.method === 'GET' && req.path === '/health')) {
1623
+ return res.status(403).json({ error: 'non concesso a questo nodo', reason: 'host-only-peer' });
1624
+ }
1625
+ return next();
1626
+ });
1365
1627
  // Health federato: il peer autenticato (acceptToken matchato sopra) ottiene un
1366
1628
  // 200 esplicito con instanceId/version. Serve da target di probeHealth() lato
1367
1629
  // Initiator: distingue transport (porta aperta) da auth (200 vs 401) da
@@ -1683,10 +1945,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1683
1945
  let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
1684
1946
  // Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
1685
1947
  let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
1948
+ let hostOnlyEgress = false; let hostOnlyGuard = null;
1686
1949
  if (parsed.route.length) {
1687
1950
  const next = store.getNode(st, parsed.route[0]);
1688
1951
  const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
1689
- if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
1952
+ if (next && isHostOnlyPeer(next) && isPanelResource(parsed.resource)) return reject(socket, 403);
1953
+ if (!next || !next.token || privateInbound
1954
+ || (ingress && (!canTransit(ingress, next) || !chainInAudience(st, visited, next)))) return reject(socket, 403);
1955
+ hostOnlyEgress = isHostOnlyPeer(next);
1956
+ if (ingress) hostOnlyGuard = streamGuardFor(nodesPath, ingress, next, visited);
1690
1957
  port = next.localPort; credential = next.token;
1691
1958
  const rest = parsed.route.slice(1);
1692
1959
  path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
@@ -1707,6 +1974,13 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1707
1974
  // cambiare), l'output va scritto grezzo (i frame li ha gia' incorniciati
1708
1975
  // l'upstream).
1709
1976
  try { socket.write(handshake); } catch (_) { /* morto */ }
1977
+ if (hostOnlyGuard) {
1978
+ const untrackTaken = trackHostOnlyStream(hostOnlyGuard, () => {
1979
+ try { socket.destroy(); } catch (_) { /* gone */ }
1980
+ try { record.up.destroy(); } catch (_) { /* gone */ }
1981
+ });
1982
+ record.up.once('close', untrackTaken);
1983
+ }
1710
1984
  // Gli eventuali byte gia' arrivati dopo la richiesta di upgrade vanno
1711
1985
  // inoltrati come nel ramo normale, non persi.
1712
1986
  if (head && head.length) { try { record.up.write(head); } catch (_) { /* morto */ } }
@@ -1729,6 +2003,14 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1729
2003
  }
1730
2004
  }
1731
2005
  const up = net.connect({ host: '127.0.0.1', port });
2006
+ // What we read from the peer: for a host-only peer the head of its answer is
2007
+ // filtered like its HTTP answers.
2008
+ let upRead = up;
2009
+ if (hostOnlyEgress) {
2010
+ const headFilter = hostOnlyHeadFilter();
2011
+ headFilter.on('error', () => { try { up.destroy(); } catch (_) { /* gone */ } }); // refused head: upstreamGone() answers the client with our own bytes
2012
+ upRead = up.pipe(headFilter);
2013
+ }
1732
2014
  // ECONNREFUSED sull'hop locale (tunnel che si riavvia) arriva PRIMA del
1733
2015
  // 'connect': nel ramo hubMode il listener 'error' era registrato solo dentro
1734
2016
  // up.once('connect', …) e l'evento restava senza gestore → Unhandled 'error'
@@ -1752,7 +2034,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1752
2034
  let saw101 = false;
1753
2035
  let headBuf = Buffer.alloc(0);
1754
2036
  let gone = false;
1755
- up.on('data', (chunk) => {
2037
+ upRead.on('data', (chunk) => {
1756
2038
  if (saw101 || headBuf.length >= 1024) return;
1757
2039
  headBuf = Buffer.concat([headBuf, chunk.subarray(0, Math.max(0, 1024 - headBuf.length))]);
1758
2040
  if (headBuf.includes(' 101')) saw101 = true;
@@ -1798,6 +2080,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1798
2080
  const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
1799
2081
  socket.once('close', remove); up.once('close', remove);
1800
2082
  }
2083
+ if (hostOnlyGuard) {
2084
+ // Released when the UPSTREAM goes: a held attach keeps `up` after the
2085
+ // client has gone, and it must still be closed on revocation.
2086
+ const untrack = trackHostOnlyStream(hostOnlyGuard, () => {
2087
+ try { socket.destroy(); } catch (_) { /* gone */ }
2088
+ try { up.destroy(); } catch (_) { /* gone */ }
2089
+ });
2090
+ up.once('close', untrack);
2091
+ }
1801
2092
  if (hubMode) {
1802
2093
  // Attach trattenuta: l'output passa dal ring quando il client non c'è,
1803
2094
  // e il record entra nello store sotto la sua identità.
@@ -1805,7 +2096,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1805
2096
  attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession, up,
1806
2097
  });
1807
2098
  heldRecord = record;
1808
- up.on('data', (chunk) => hubAttaches.feed(record, chunk));
2099
+ upRead.on('data', (chunk) => hubAttaches.feed(record, chunk));
1809
2100
  up.on('close', () => hubAttaches.destroy(record));
1810
2101
  up.on('error', () => hubAttaches.destroy(record));
1811
2102
  // end:false: la caduta del CLIENT non deve chiudere l'upstream — è
@@ -1831,7 +2122,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1831
2122
  socket.pipe(up);
1832
2123
  // end:false: la chiusura verso il browser la governiamo noi, o la pipe
1833
2124
  // chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
1834
- up.pipe(socket, { end: false });
2125
+ upRead.pipe(socket, { end: false });
1835
2126
  });
1836
2127
  if (!hubMode) {
1837
2128
  // Nel ramo trattenuto la morte dell'upstream la governa lo store (destroy
@@ -2072,9 +2363,9 @@ function acceptWsThenClose(socket, req, code, reason) {
2072
2363
 
2073
2364
 
2074
2365
  module.exports = {
2075
- isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
2366
+ isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, classGateCheck, routeHandler,
2076
2367
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
2077
- peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
2368
+ peerFromToken, peerAllows, canTransit, isHostOnlyPeer, chainInAudience, touchesHostOnly, routeNamesOf, hostOnlyVisibility, hideHostOnlyEntries, redactHostOnlyReferences, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
2078
2369
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
2079
2370
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
2080
2371
  hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
@@ -22,6 +22,7 @@
22
22
  // raw socket TCP (upgrade WS). resolveNode/httpRequest/connect iniettabili (test).
23
23
  const http = require('node:http');
24
24
  const net = require('node:net');
25
+ const { Transform } = require('node:stream');
25
26
  const { NODE_NAME_RE } = require('../nodes/store.js');
26
27
  const { bearerFrom } = require('../auth/middleware.js');
27
28
 
@@ -142,6 +143,77 @@ function sanitizeResponseHeaders(headers) {
142
143
  return out;
143
144
  }
144
145
 
146
+ // Answers of a host-only peer: the peer is published one way, so nothing it says
147
+ // may act on the origin of the PWA. Its cookies, its Clear-Site-Data and its CSP
148
+ // are dropped; the body is served sandboxed and without MIME sniffing.
149
+ const HOST_ONLY_STRIPPED_HEADERS = new Set([
150
+ 'set-cookie', 'set-cookie2', 'clear-site-data', 'content-security-policy', 'content-security-policy-report-only',
151
+ 'x-content-type-options', 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy',
152
+ 'location', 'content-location', 'refresh',
153
+ ]);
154
+ function sanitizeHostOnlyResponseHeaders(headers) {
155
+ const out = {};
156
+ for (const [k, v] of Object.entries(sanitizeResponseHeaders(headers))) {
157
+ if (HOST_ONLY_STRIPPED_HEADERS.has(k.toLowerCase())) continue;
158
+ out[k] = v;
159
+ }
160
+ out['content-security-policy'] = "sandbox; default-src 'none'";
161
+ out['x-content-type-options'] = 'nosniff';
162
+ return out;
163
+ }
164
+
165
+ // The head of an upgrade answer from a host-only peer: the same headers dropped as on the HTTP
166
+ // answers (cookies, Clear-Site-Data, CSP). The head is parsed the way the most lenient consumer
167
+ // would read it, so what is dropped here cannot reappear there: the end of the head is the first
168
+ // empty line (CRLF or bare LF), lines end with CRLF or bare LF, folded (continuation) lines are
169
+ // unfolded, a header name is a strict token. Anything that does not parse that way — a bare CR, a
170
+ // NUL, a line without a colon or with a malformed name, a first line that is not a status line, a
171
+ // head over 16 KB — is refused (the stream errors, the connection is closed) instead of passed
172
+ // along. Bytes after the head pass as they are.
173
+ const HOST_ONLY_HEAD_DROPPED = new Set(['set-cookie', 'set-cookie2', 'clear-site-data', 'content-security-policy', 'content-security-policy-report-only']);
174
+ const HOST_ONLY_HEAD_MAX = 16384;
175
+ function sanitizeUpgradeHead(headText) {
176
+ if (/\0/.test(headText) || /\r(?!\n)/.test(headText)) throw new Error('upgrade head with a NUL or a bare CR');
177
+ const raw = headText.split(/\r?\n/);
178
+ // The ONLY answer to an upgrade that is passed along is a 101: a refusal, a 100 Continue (an interim answer
179
+ // that would let a second head slip through) or anything else is refused here.
180
+ if (!/^HTTP\/1\.[01] 101(?: [^\r\n]*)?$/.test(raw[0])) throw new Error('upgrade answer of a host-only peer is not a 101');
181
+ const logical = [];
182
+ for (const line of raw.slice(1)) {
183
+ if (/^[ \t]/.test(line)) { // obs-fold: continuation of the previous header
184
+ if (!logical.length) throw new Error('upgrade head with a continuation before any header');
185
+ logical[logical.length - 1] += ` ${line.trim()}`;
186
+ } else logical.push(line);
187
+ }
188
+ const kept = [raw[0]];
189
+ for (const line of logical) {
190
+ const i = line.indexOf(':');
191
+ if (i <= 0) throw new Error('upgrade head with a malformed header line');
192
+ const name = line.slice(0, i).trim();
193
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) throw new Error('upgrade head with a malformed header name');
194
+ if (HOST_ONLY_HEAD_DROPPED.has(name.toLowerCase())) continue;
195
+ kept.push(`${name}:${line.slice(i + 1)}`);
196
+ }
197
+ return `${kept.join('\r\n')}\r\n\r\n`;
198
+ }
199
+ function hostOnlyHeadFilter() {
200
+ let buf = Buffer.alloc(0); let done = false;
201
+ return new Transform({
202
+ transform(chunk, _enc, cb) {
203
+ if (done) return cb(null, chunk);
204
+ buf = Buffer.concat([buf, chunk]);
205
+ const window = buf.toString('latin1', 0, Math.min(buf.length, HOST_ONLY_HEAD_MAX + 4));
206
+ const m = /\r?\n\r?\n/.exec(window);
207
+ if (!m || m.index > HOST_ONLY_HEAD_MAX) return buf.length > HOST_ONLY_HEAD_MAX ? cb(new Error('upgrade head too large')) : cb();
208
+ let head;
209
+ try { head = sanitizeUpgradeHead(window.slice(0, m.index)); } catch (e) { return cb(e); }
210
+ const rest = buf.subarray(m.index + m[0].length);
211
+ done = true; buf = null;
212
+ return cb(null, Buffer.concat([Buffer.from(head, 'latin1'), rest]));
213
+ },
214
+ });
215
+ }
216
+
145
217
  function notFound(res) { res.status(404).json({ error: 'not found' }); }
146
218
 
147
219
  // --- HTTP proxy middleware --------------------------------------------------
@@ -186,7 +258,12 @@ function proxyHttp(req, res, node, rest, search, httpRequest, timeoutMs) {
186
258
  let upstream;
187
259
  try {
188
260
  upstream = httpRequest(options, (up) => {
189
- res.writeHead(up.statusCode, sanitizeResponseHeaders(up.headers));
261
+ if (node.hostOnly === true && up.statusCode >= 300 && up.statusCode < 400) {
262
+ // A redirect from a host-only peer would be followed by the browser WITH the owner's Authorization.
263
+ up.resume();
264
+ return res.status(502).json({ error: 'redirect refused', reason: 'host-only-redirect' });
265
+ }
266
+ res.writeHead(up.statusCode, (node.hostOnly === true ? sanitizeHostOnlyResponseHeaders : sanitizeResponseHeaders)(up.headers));
190
267
  up.pipe(res);
191
268
  });
192
269
  } catch (_) {
@@ -327,7 +404,13 @@ function handleNodeUpgrade(ctx) {
327
404
  socket.on('error', () => { try { upstream.destroy(); } catch (_) {} });
328
405
  upstream.on('error', () => { try { socket.destroy(); } catch (_) {} });
329
406
  socket.pipe(upstream);
330
- upstream.pipe(socket);
407
+ let down = upstream;
408
+ if (node.hostOnly === true) {
409
+ const filter = hostOnlyHeadFilter();
410
+ filter.on('error', () => { try { upstream.destroy(); } catch (_) {} abortUpgrade(socket, 502); });
411
+ down = upstream.pipe(filter);
412
+ }
413
+ down.pipe(socket);
331
414
  });
332
415
  upstream.on('error', () => { clearTimeout(timer); fail(502); });
333
416
  }
@@ -340,7 +423,7 @@ module.exports = {
340
423
  createNodeProxy,
341
424
  handleNodeUpgrade,
342
425
  // esposti per i test
343
- splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders,
426
+ splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders, sanitizeHostOnlyResponseHeaders, hostOnlyHeadFilter,
344
427
  buildUpgradeRequest, stripLocalTokenQuery, isStrippedRequestHeader, connectionOptions, bearerFromUpgrade,
345
428
  MUTATING, HOP_BY_HOP, PROXY_TIMEOUT_MS,
346
429
  };