@mmmbuto/nexuscrew 0.9.66 → 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 +15 -1
- package/docs/NODES.md +14 -3
- package/frontend/dist/assets/{index-DhZOap1-.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/live-host/federation.js +10 -0
- package/lib/live-host/routes.js +12 -2
- package/lib/nodes/reverse-slot-listeners.js +4 -1
- package/lib/nodes/store.js +10 -2
- package/lib/proxy/federation.js +266 -14
- package/lib/proxy/node-proxy.js +86 -3
- package/lib/server.js +98 -7
- package/package.json +1 -1
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-D__76yWR.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-Dwd37Z_R.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.67"}
|
package/lib/audio/dispatch.js
CHANGED
|
@@ -30,6 +30,10 @@ function createDispatcher(opts = {}) {
|
|
|
30
30
|
// stesso percorso e rispondono `delivered`. Il default resta quello audio,
|
|
31
31
|
// cosi' nessun chiamante esistente cambia comportamento.
|
|
32
32
|
const statuses = opts.statuses instanceof Set ? opts.statuses : PER_ENDPOINT_STATUS;
|
|
33
|
+
// Policy for requests that arrived FEDERATED and terminate here: re-read on the
|
|
34
|
+
// resolution used for the I/O, just before the request leaves. Local callers are
|
|
35
|
+
// never subject to it.
|
|
36
|
+
const federatedGuard = typeof opts.federatedGuard === 'function' ? opts.federatedGuard : null;
|
|
33
37
|
|
|
34
38
|
function isLocal(target) {
|
|
35
39
|
const self = localNodeId();
|
|
@@ -143,10 +147,19 @@ function createDispatcher(opts = {}) {
|
|
|
143
147
|
resolveRoute,
|
|
144
148
|
probeCapability,
|
|
145
149
|
// dispatch(): unico ingresso per un target remoto.
|
|
146
|
-
async dispatch({ resource, target, origin, payload }) {
|
|
150
|
+
async dispatch({ resource, target, origin, payload, federated = false }) {
|
|
151
|
+
// What the policy says when the request begins counts too: a publication that ends while the topology is
|
|
152
|
+
// awaited does not turn the request into an allowed one.
|
|
153
|
+
let deniedBefore = false;
|
|
154
|
+
if (federated && federatedGuard) { try { deniedBefore = federatedGuard({ target, route: [] }) !== false; } catch (_) { deniedBefore = true; } }
|
|
147
155
|
const resolved = await resolveRoute(target);
|
|
148
156
|
if (resolved.error === 'invalid-target') return { status: 'refused', reason: 'invalid-target' };
|
|
149
157
|
if (resolved.error) return { status: 'unreachable', reason: resolved.error };
|
|
158
|
+
if (federated && federatedGuard) {
|
|
159
|
+
let denied = true;
|
|
160
|
+
try { denied = federatedGuard({ target, route: resolved.route, peer: resolved.peer }) !== false; } catch (_) { denied = true; }
|
|
161
|
+
if (denied || deniedBefore) return { status: 'refused', reason: 'host-only-target' };
|
|
162
|
+
}
|
|
150
163
|
return forward(resource, resolved.route, {
|
|
151
164
|
...payload,
|
|
152
165
|
target,
|
package/lib/audio/routes.js
CHANGED
|
@@ -251,7 +251,7 @@ function audioRoutes(deps = {}) {
|
|
|
251
251
|
origin, target, status: 'accepted', utteranceId, attested: trust === 'federated',
|
|
252
252
|
});
|
|
253
253
|
const out = await dispatcher.dispatch({
|
|
254
|
-
resource: '/audio/speak', target, origin,
|
|
254
|
+
resource: '/audio/speak', target, origin, federated: trust === 'federated',
|
|
255
255
|
payload: {
|
|
256
256
|
text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
|
|
257
257
|
utteranceId: local.utteranceId,
|
|
@@ -285,7 +285,7 @@ function audioRoutes(deps = {}) {
|
|
|
285
285
|
const { target } = parsed;
|
|
286
286
|
if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
|
|
287
287
|
const out = await dispatcher.dispatch({
|
|
288
|
-
resource: '/audio/stop', target, origin,
|
|
288
|
+
resource: '/audio/stop', target, origin, federated: resolved.trust === 'federated',
|
|
289
289
|
payload: { ...(parsed.utteranceId ? { utteranceId: parsed.utteranceId } : {}) },
|
|
290
290
|
});
|
|
291
291
|
return send(res, out.status === 'accepted' ? 200 : out.status === 'unreachable' ? 503 : 422, out);
|
|
@@ -30,6 +30,9 @@ function createLiveHostFederation({
|
|
|
30
30
|
localToken = () => '',
|
|
31
31
|
fetchImpl = fetch,
|
|
32
32
|
timeoutMs = FED_TIMEOUT_MS,
|
|
33
|
+
// Refuses an owner that is (or routes through) a host-only peer. Read on the
|
|
34
|
+
// resolution used for the I/O, just before the request leaves.
|
|
35
|
+
ownerGuard = null,
|
|
33
36
|
} = {}) {
|
|
34
37
|
async function resolveRoute(target) {
|
|
35
38
|
if (!INSTANCE_ID_RE.test(String(target || ''))) return { error: 'invalid-target' };
|
|
@@ -48,11 +51,18 @@ function createLiveHostFederation({
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
async function forward(target, resource, method, body) {
|
|
54
|
+
let deniedBefore = false;
|
|
55
|
+
if (typeof ownerGuard === 'function') { try { deniedBefore = ownerGuard({ target, route: [] }) !== false; } catch (_) { deniedBefore = true; } }
|
|
51
56
|
const resolved = await resolveRoute(target);
|
|
52
57
|
if (resolved.error) return { ok: false, reason: resolved.error === 'invalid-target' ? 'invalid-target' : 'live-host-owner-unreachable' };
|
|
53
58
|
const port = localPort();
|
|
54
59
|
const token = localToken();
|
|
55
60
|
if (!port || !token) return { ok: false, reason: 'live-host-owner-unreachable' };
|
|
61
|
+
if (typeof ownerGuard === 'function') {
|
|
62
|
+
let denied = true;
|
|
63
|
+
try { denied = ownerGuard({ target, route: resolved.route }) !== false; } catch (_) { denied = true; }
|
|
64
|
+
if (denied || deniedBefore) return { ok: false, reason: 'live-host-owner-unreachable' };
|
|
65
|
+
}
|
|
56
66
|
const url = `http://127.0.0.1:${port}/api/route/${resolved.route.join('/')}/_${resource}`;
|
|
57
67
|
let res;
|
|
58
68
|
try {
|
package/lib/live-host/routes.js
CHANGED
|
@@ -95,7 +95,10 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
|
|
|
95
95
|
// verifica (prova di hop + catena `visited` costruita dal server), e il budget
|
|
96
96
|
// del ponte si applica per NODO mittente ATTESTATO — stessa regola e stessi
|
|
97
97
|
// limiti dell'audio (lib/audio/rate-limit.js).
|
|
98
|
-
federation = null, originResolver = null, federatedRate = null
|
|
98
|
+
federation = null, originResolver = null, federatedRate = null,
|
|
99
|
+
// (req, ownerId) => true when this reader must not learn that the designation
|
|
100
|
+
// points at a host-only peer.
|
|
101
|
+
ownerHiddenFor = null }) {
|
|
99
102
|
const r = express.Router();
|
|
100
103
|
|
|
101
104
|
// Una designazione con `ownerId` e' REMOTA: il puntamento vive qui,
|
|
@@ -124,11 +127,18 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
|
|
|
124
127
|
// hostCell e revision vengono dallo store (preservato); eligible e' la verita'
|
|
125
128
|
// COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
|
|
126
129
|
// stato del lease della cella designata, distinto, perche' chi legge distingue.
|
|
127
|
-
r.get('/', async (
|
|
130
|
+
r.get('/', async (req, res) => {
|
|
128
131
|
try {
|
|
129
132
|
const snap = store.snapshot();
|
|
133
|
+
if (snap.hostCell != null && snap.ownerId && ownerHiddenFor && ownerHiddenFor(req, snap.ownerId)) {
|
|
134
|
+
return res.json({ hostCell: null, revision: snap.revision, eligible: false, threadStatus: 'absent', host: { lease: null }, at: now() });
|
|
135
|
+
}
|
|
130
136
|
if (snap.hostCell != null && snap.ownerId) {
|
|
131
137
|
const remote = await remoteOwnerState(snap);
|
|
138
|
+
// the reader is judged again after the wait: a publication that changed meanwhile must not name the owner
|
|
139
|
+
if (ownerHiddenFor && ownerHiddenFor(req, snap.ownerId)) {
|
|
140
|
+
return res.json({ hostCell: null, revision: snap.revision, eligible: false, threadStatus: 'absent', host: { lease: null }, at: now() });
|
|
141
|
+
}
|
|
132
142
|
return res.json({
|
|
133
143
|
hostCell: snap.hostCell, revision: snap.revision, ownerId: snap.ownerId, remote: true,
|
|
134
144
|
eligible: remote.state ? remote.state.eligible : false,
|
|
@@ -83,7 +83,10 @@ function createReverseSlotListeners({ app, createServerImpl = http.createServer,
|
|
|
83
83
|
return true;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
// A request that arrived on one of these listeners comes from a peer's channel,
|
|
87
|
+
// never from this machine's own browser: callers use it to tell the two apart.
|
|
88
|
+
const isSlotPort = (localPort) => listeners.has(localPort);
|
|
89
|
+
return { open, closePort, closeAll, respond, isSlotPort, size: () => listeners.size };
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
module.exports = { createReverseSlotListeners };
|
package/lib/nodes/store.js
CHANGED
|
@@ -958,8 +958,16 @@ function backupStoreFile(filePath, now = new Date()) {
|
|
|
958
958
|
const data = fs.readFileSync(filePath);
|
|
959
959
|
const sha256 = crypto.createHash('sha256').update(data).digest('hex');
|
|
960
960
|
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
961
|
-
|
|
962
|
-
|
|
961
|
+
// wx: never overwrite a previous backup. Two backups of the same bytes in the same second get distinct names
|
|
962
|
+
// (a numeric suffix) instead of failing the opt-in.
|
|
963
|
+
let target = '';
|
|
964
|
+
for (let n = 0; ; n += 1) {
|
|
965
|
+
target = `${filePath}.pre-publish-remote-${stamp}-${sha256.slice(0, 8)}${n ? `-${n}` : ''}.bak`;
|
|
966
|
+
try { fs.writeFileSync(target, data, { mode: 0o600, flag: 'wx' }); break; } catch (e) {
|
|
967
|
+
if (e && e.code === 'EEXIST' && n < 1000) continue;
|
|
968
|
+
throw e;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
963
971
|
fs.chmodSync(target, 0o600);
|
|
964
972
|
return { path: target, sha256 };
|
|
965
973
|
}
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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;
|
|
@@ -144,6 +146,175 @@ function canTransit(ingress, egress) {
|
|
|
144
146
|
&& peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
|
|
145
147
|
}
|
|
146
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
|
+
|
|
147
318
|
function parseRoute(raw) {
|
|
148
319
|
const parts = String(raw || '').split('?')[0].split('/').filter(Boolean);
|
|
149
320
|
const idx = parts.indexOf(ROUTE_DELIMITER);
|
|
@@ -515,7 +686,7 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
|
|
|
515
686
|
const SSE_IDLE_TIMEOUT_MS = 90000;
|
|
516
687
|
const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
|
|
517
688
|
|
|
518
|
-
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 }) {
|
|
519
690
|
// A body a local middleware has already read is no longer in the stream: the
|
|
520
691
|
// headers would announce bytes that nothing sends, and the peer would wait for
|
|
521
692
|
// a body that never arrives. Say it at once, with a name, instead of letting
|
|
@@ -533,8 +704,23 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
533
704
|
if (r.statusCode < 200 || r.statusCode >= 300) failed();
|
|
534
705
|
r.once('aborted', failed); r.once('error', failed);
|
|
535
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
|
+
}
|
|
536
722
|
if (!capture) {
|
|
537
|
-
res.writeHead(r.statusCode,
|
|
723
|
+
res.writeHead(r.statusCode, cleanResponse(headers));
|
|
538
724
|
if (SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || ''))) {
|
|
539
725
|
// The response is open: no inactivity timeout any more (the 504 below
|
|
540
726
|
// still covers a peer that never answers), and the watchdog is rearmed
|
|
@@ -568,7 +754,7 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
568
754
|
const body = Buffer.concat(chunks);
|
|
569
755
|
try { capture(r.statusCode, body.toString('utf8')); } catch (_) { /* the ledger never breaks the forward */ }
|
|
570
756
|
if (res.writableEnded) return;
|
|
571
|
-
res.writeHead(r.statusCode,
|
|
757
|
+
res.writeHead(r.statusCode, cleanResponse(headers));
|
|
572
758
|
res.end(body);
|
|
573
759
|
});
|
|
574
760
|
});
|
|
@@ -668,7 +854,16 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
668
854
|
}
|
|
669
855
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
670
856
|
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
671
|
-
|
|
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' });
|
|
672
867
|
if (parsed.resource === '/asks' && req.method === 'POST') {
|
|
673
868
|
const view = accessPresets.grantsOf(next);
|
|
674
869
|
if (!view.configured || view.label !== 'admin') {
|
|
@@ -739,7 +934,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
739
934
|
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) {
|
|
740
935
|
return rejectForward(403, { error: 'READONLY: federated mutation blocked' });
|
|
741
936
|
}
|
|
742
|
-
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)))) {
|
|
743
938
|
return rejectForward(403, { error: 'route non consentita' });
|
|
744
939
|
}
|
|
745
940
|
if (!controlledVisited(req, currentIngress, current.nodeId)) return rejectForward(409, { error: 'federation cycle rejected' });
|
|
@@ -751,6 +946,8 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
751
946
|
try { JSON.parse(text); } catch (_) { invalidateProof(); }
|
|
752
947
|
} : capture;
|
|
753
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,
|
|
754
951
|
onForwardFailure: isAskAction ? invalidateProof : null, abortWithClient: isAskAction, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
|
|
755
952
|
};
|
|
756
953
|
}
|
|
@@ -1226,7 +1423,7 @@ async function collectTopologyDetailed({
|
|
|
1226
1423
|
// clients become part of Hydra only after their explicit Share toggle.
|
|
1227
1424
|
if (!n.nodeId || seen.has(n.nodeId)
|
|
1228
1425
|
|| (!ingress && n.direction === 'inbound' && n.shared !== true)
|
|
1229
|
-
|| (ingress && !canTransit(ingress, n))) continue;
|
|
1426
|
+
|| (ingress && (!canTransit(ingress, n) || !chainInAudience(st, [...seen, ingress.nodeId], n)))) continue;
|
|
1230
1427
|
// `name` e' lo slug locale con cui si indirizza; `label` e' il nome
|
|
1231
1428
|
// leggibile. Senza quest'ultimo un nodo raggiunto in transito arriva agli
|
|
1232
1429
|
// altri come uno slug scelto da qualcun altro, e cinque installazioni
|
|
@@ -1289,12 +1486,38 @@ async function collectTopologyDetailed({
|
|
|
1289
1486
|
});
|
|
1290
1487
|
}
|
|
1291
1488
|
}
|
|
1292
|
-
const ids = new Set(); const routes = new Set();
|
|
1489
|
+
const ids = new Set(); const routes = new Set(); let unique = [];
|
|
1293
1490
|
for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
|
|
1294
1491
|
const routeKey = n.route.join('/');
|
|
1295
1492
|
if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
|
|
1296
1493
|
ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
|
|
1297
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
|
+
}
|
|
1298
1521
|
return { instanceId: st.nodeId, nodes: unique, authoritative };
|
|
1299
1522
|
}
|
|
1300
1523
|
|
|
@@ -1722,10 +1945,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1722
1945
|
let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
|
|
1723
1946
|
// Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
|
|
1724
1947
|
let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
|
|
1948
|
+
let hostOnlyEgress = false; let hostOnlyGuard = null;
|
|
1725
1949
|
if (parsed.route.length) {
|
|
1726
1950
|
const next = store.getNode(st, parsed.route[0]);
|
|
1727
1951
|
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
1728
|
-
if (
|
|
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);
|
|
1729
1957
|
port = next.localPort; credential = next.token;
|
|
1730
1958
|
const rest = parsed.route.slice(1);
|
|
1731
1959
|
path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
|
|
@@ -1746,6 +1974,13 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1746
1974
|
// cambiare), l'output va scritto grezzo (i frame li ha gia' incorniciati
|
|
1747
1975
|
// l'upstream).
|
|
1748
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
|
+
}
|
|
1749
1984
|
// Gli eventuali byte gia' arrivati dopo la richiesta di upgrade vanno
|
|
1750
1985
|
// inoltrati come nel ramo normale, non persi.
|
|
1751
1986
|
if (head && head.length) { try { record.up.write(head); } catch (_) { /* morto */ } }
|
|
@@ -1768,6 +2003,14 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1768
2003
|
}
|
|
1769
2004
|
}
|
|
1770
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
|
+
}
|
|
1771
2014
|
// ECONNREFUSED sull'hop locale (tunnel che si riavvia) arriva PRIMA del
|
|
1772
2015
|
// 'connect': nel ramo hubMode il listener 'error' era registrato solo dentro
|
|
1773
2016
|
// up.once('connect', …) e l'evento restava senza gestore → Unhandled 'error'
|
|
@@ -1791,7 +2034,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1791
2034
|
let saw101 = false;
|
|
1792
2035
|
let headBuf = Buffer.alloc(0);
|
|
1793
2036
|
let gone = false;
|
|
1794
|
-
|
|
2037
|
+
upRead.on('data', (chunk) => {
|
|
1795
2038
|
if (saw101 || headBuf.length >= 1024) return;
|
|
1796
2039
|
headBuf = Buffer.concat([headBuf, chunk.subarray(0, Math.max(0, 1024 - headBuf.length))]);
|
|
1797
2040
|
if (headBuf.includes(' 101')) saw101 = true;
|
|
@@ -1837,6 +2080,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1837
2080
|
const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
|
|
1838
2081
|
socket.once('close', remove); up.once('close', remove);
|
|
1839
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
|
+
}
|
|
1840
2092
|
if (hubMode) {
|
|
1841
2093
|
// Attach trattenuta: l'output passa dal ring quando il client non c'è,
|
|
1842
2094
|
// e il record entra nello store sotto la sua identità.
|
|
@@ -1844,7 +2096,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1844
2096
|
attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession, up,
|
|
1845
2097
|
});
|
|
1846
2098
|
heldRecord = record;
|
|
1847
|
-
|
|
2099
|
+
upRead.on('data', (chunk) => hubAttaches.feed(record, chunk));
|
|
1848
2100
|
up.on('close', () => hubAttaches.destroy(record));
|
|
1849
2101
|
up.on('error', () => hubAttaches.destroy(record));
|
|
1850
2102
|
// end:false: la caduta del CLIENT non deve chiudere l'upstream — è
|
|
@@ -1870,7 +2122,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1870
2122
|
socket.pipe(up);
|
|
1871
2123
|
// end:false: la chiusura verso il browser la governiamo noi, o la pipe
|
|
1872
2124
|
// chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
|
|
1873
|
-
|
|
2125
|
+
upRead.pipe(socket, { end: false });
|
|
1874
2126
|
});
|
|
1875
2127
|
if (!hubMode) {
|
|
1876
2128
|
// Nel ramo trattenuto la morte dell'upstream la governa lo store (destroy
|
|
@@ -2113,7 +2365,7 @@ function acceptWsThenClose(socket, req, code, reason) {
|
|
|
2113
2365
|
module.exports = {
|
|
2114
2366
|
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, classGateCheck, routeHandler,
|
|
2115
2367
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
2116
|
-
peerFromToken, peerAllows, canTransit, isHostOnlyPeer, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
2368
|
+
peerFromToken, peerAllows, canTransit, isHostOnlyPeer, chainInAudience, touchesHostOnly, routeNamesOf, hostOnlyVisibility, hideHostOnlyEntries, redactHostOnlyReferences, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
2117
2369
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
|
|
2118
2370
|
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
|
2119
2371
|
hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
|