blockyard 0.0.1 → 0.1.0
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 +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
// API surface. Every handler returns a plain object; the server serialises it and
|
|
2
|
+
// turns thrown HttpError into a JSON envelope. Nothing here writes to the node
|
|
3
|
+
// except the /action route, which is opt-in, role-gated and audited.
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { classifyMethod, allowlistSummary, ACTIONS, actionAllowed, NODE_REFUSES } from '../rpc/allowlist.js';
|
|
7
|
+
import { RpcClient } from '../rpc/client.js';
|
|
8
|
+
import { SERIES } from '../store/history.js';
|
|
9
|
+
import { randomPassword } from '../auth/users.js';
|
|
10
|
+
import { formatEta, formatBytes } from '../util/fmt.js';
|
|
11
|
+
import { xSearch, xTx, xBlock, xAddress } from './explorer.js';
|
|
12
|
+
|
|
13
|
+
// Dollar figures for the explorer: a spot price if one is at hand within 1.5 s -- never a slower
|
|
14
|
+
// page for want of one (server/collect/markets.js spot()).
|
|
15
|
+
// THE MARKET SWITCH (operator, 2026-09-15: "disable markets by default so we can claim true zero
|
|
16
|
+
// telemetry out of the box" ... "an app wide 'Enable Market Polling' checkbox"). Two layers:
|
|
17
|
+
// - BLOCKYARD_MARKETS=0 (markets.enabled=false) removes the feed from the server altogether;
|
|
18
|
+
// nothing in the browser can turn it on. For machines that must never reach out.
|
|
19
|
+
// - otherwise the feed exists but polls only while the Display setting
|
|
20
|
+
// markets.polling is on -- and that ships OFF, so a fresh install makes no outbound
|
|
21
|
+
// connection but to the node until someone ticks the box.
|
|
22
|
+
// The setting lives in the server's settings file (config/blockyard.json, the same one every
|
|
23
|
+
// screen shares); it is read here per request, cached on the file's mtime and size, so a tick in
|
|
24
|
+
// the panel takes effect on the next call without a restart -- and the next call also PARKS the
|
|
25
|
+
// feed, so unticking stops the exchange traffic at once rather than ten minutes later.
|
|
26
|
+
const MARKETS_OFF = 'market data is off on this server (BLOCKYARD_MARKETS=0 or markets.enabled=false); the switch in Display settings cannot turn it on';
|
|
27
|
+
const POLLING_OFF = 'market polling is off -- the default, so that out of the box this monitor makes no outbound connection but to your node. Turn it on under Display settings → Markets & Price → Enable market polling';
|
|
28
|
+
const pollingCache = new WeakMap();
|
|
29
|
+
export async function marketsPollingOn(app) {
|
|
30
|
+
const file = app.settingsFile;
|
|
31
|
+
if (!file) return false;
|
|
32
|
+
let st;
|
|
33
|
+
try { st = await fsp.stat(file); } catch { return false; }
|
|
34
|
+
const hit = pollingCache.get(app);
|
|
35
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.value;
|
|
36
|
+
let value = false;
|
|
37
|
+
try { value = JSON.parse(await fsp.readFile(file, 'utf8'))?.markets?.polling === true; } catch { value = false; }
|
|
38
|
+
pollingCache.set(app, { mtimeMs: st.mtimeMs, size: st.size, value });
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
const pollingOff = (app) => { app.markets?.stop?.(); return { ok: true, enabled: false, polling: false, note: POLLING_OFF }; };
|
|
42
|
+
|
|
43
|
+
async function withUsd(app, r) {
|
|
44
|
+
if (!r?.ok || !app.markets || !(await marketsPollingOn(app))) return r;
|
|
45
|
+
const p = await Promise.race([app.markets.spot().catch(() => null), new Promise((res) => { setTimeout(res, 1500, null).unref?.(); })]);
|
|
46
|
+
return { ...r, usd: p?.usd ?? null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class HttpError extends Error {
|
|
50
|
+
constructor(status, message, { code = null, detail = null } = {}) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'HttpError';
|
|
53
|
+
this.status = status;
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.detail = detail;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ban = (user) => ({ ok: false, reason: 'CSRF token missing or incorrect', status: 403, user });
|
|
60
|
+
|
|
61
|
+
function needRole(ctx, role) {
|
|
62
|
+
const rank = { viewer: 0, operator: 1, admin: 2 };
|
|
63
|
+
if ((rank[ctx.user?.role] ?? -1) < (rank[role] ?? 99)) {
|
|
64
|
+
// In open mode the shortfall is structural, not personal: name the switch that
|
|
65
|
+
// changes it instead of telling an anonymous visitor they lack a role they have
|
|
66
|
+
// no way to acquire.
|
|
67
|
+
if (ctx.app?.cfg?.auth?.enabled === false) {
|
|
68
|
+
throw new HttpError(403, 'accounts are disabled, so this endpoint has no one to authorise; start with BLOCKYARD_AUTH=1 to enable sign-in, users and the audit trail');
|
|
69
|
+
}
|
|
70
|
+
throw new HttpError(403, `this needs role "${role}"; you are "${ctx.user?.role ?? 'anonymous'}"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickNode(ctx, app) {
|
|
75
|
+
const wanted = ctx.query.node || ctx.query.nodeId;
|
|
76
|
+
if (!wanted) return app.primary;
|
|
77
|
+
const m = app.monitors.get(wanted);
|
|
78
|
+
if (!m) throw new HttpError(404, `no node "${wanted}"; known: ${[...app.monitors.keys()].join(', ')}`);
|
|
79
|
+
return m;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- the node-connection form's two guards ------------------------------------------------
|
|
83
|
+
// (operator, asked which posture to take: "D - Want this easy to configure and going to assume
|
|
84
|
+
// it's on a safe network".)
|
|
85
|
+
//
|
|
86
|
+
// With accounts ON, this is an admin action like any other. With accounts OFF there is no identity
|
|
87
|
+
// to check, and the operator chose to allow it rather than demand BLOCKYARD_AUTH just to point the
|
|
88
|
+
// monitor at a node. Every save is audited.
|
|
89
|
+
//
|
|
90
|
+
// CSRF: the double-submit check only runs when there IS a session (server.js), so with accounts
|
|
91
|
+
// off it never fired here -- the claim this comment used to make ("CSRF still applies in both
|
|
92
|
+
// cases") was false, and a cross-site form could post to these routes. Open mode now refuses a
|
|
93
|
+
// cross-site Origin / Sec-Fetch-Site on every csrf:true route instead.
|
|
94
|
+
//
|
|
95
|
+
// This is deliberately NOT the /api/action posture. That gate refuses node writes while accounts
|
|
96
|
+
// are off because those commands reach the NODE. This reaches only this app's own config file.
|
|
97
|
+
function configWriteAllowed(app, ctx) {
|
|
98
|
+
if (app.cfg.auth.enabled) needRole(ctx, 'admin');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What a form may set, and nothing else. Credentials come from the datadir's .cookie
|
|
102
|
+
// (config.js resolveCookie), so rpcUser / rpcPassword / cookieFile are NOT accepted here: taking a
|
|
103
|
+
// password over an endpoint that is open by default is not a thing to add quietly.
|
|
104
|
+
function candidateNode(app, body) {
|
|
105
|
+
const cur = app.cfg.nodes?.[0] ?? {};
|
|
106
|
+
const rpcUrl = String(body?.rpcUrl ?? '').trim();
|
|
107
|
+
const datadir = String(body?.datadir ?? '').trim();
|
|
108
|
+
const chainHint = String(body?.chainHint ?? '').trim() || cur.chainHint || 'main';
|
|
109
|
+
const label = String(body?.label ?? '').trim();
|
|
110
|
+
// THE SAME RULES config.js applies at boot, so a save cannot write a file that then refuses to
|
|
111
|
+
// load -- a monitor that saves a configuration and will not start again is the worst outcome here.
|
|
112
|
+
if (!/^https?:\/\//.test(rpcUrl)) {
|
|
113
|
+
throw new HttpError(400, 'rpcUrl must be http(s)://host:port', { code: 'bad_rpc_url' });
|
|
114
|
+
}
|
|
115
|
+
try { new URL(rpcUrl); } catch { throw new HttpError(400, `rpcUrl is not a URL: ${rpcUrl}`, { code: 'bad_rpc_url' }); }
|
|
116
|
+
const dd = datadir || cur.datadir || '';
|
|
117
|
+
if (!dd && !cur.cookieFile) {
|
|
118
|
+
throw new HttpError(400, 'a datadir is needed so the node’s .cookie can be read for authentication', { code: 'need_datadir' });
|
|
119
|
+
}
|
|
120
|
+
// ONLY THE FOUR FIELDS THIS FORM OWNS. The save merges these onto whatever the file already
|
|
121
|
+
// says, so everything else survives by not being mentioned -- which is both simpler and safer
|
|
122
|
+
// than carrying the in-memory node across.
|
|
123
|
+
//
|
|
124
|
+
// Spreading `...cur` here was the first cut and it was wrong twice over. Measured: the running
|
|
125
|
+
// config holds `logFile: null` (the key EXISTS, with a null value), so spreading it would write
|
|
126
|
+
// null over a real path in the file and silently unconfigure the log tail -- the very regression
|
|
127
|
+
// this endpoint is supposed to avoid. And `cur` also holds values the ENVIRONMENT put there, so a
|
|
128
|
+
// save would quietly bake a systemd drop-in's override into the file as though it had been
|
|
129
|
+
// chosen here.
|
|
130
|
+
return { rpcUrl, datadir: dd, chainHint, ...(label || cur.label ? { label: label || cur.label } : {}) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseRange(text, fallbackMs = 3600_000) {
|
|
134
|
+
if (!text) return fallbackMs;
|
|
135
|
+
const m = String(text).match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
|
|
136
|
+
if (!m) return fallbackMs;
|
|
137
|
+
const n = Number(m[1]);
|
|
138
|
+
const unit = (m[2] || 's').toLowerCase();
|
|
139
|
+
const mult = { s: 1000, m: 60_000, h: 3600_000, d: 86_400_000 }[unit];
|
|
140
|
+
return Math.max(1000, Math.min(31 * 86_400_000, n * mult));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const RANGES = { '15m': 900_000, '1h': 3600_000, '6h': 21600_000, '24h': 86400_000, '7d': 604800_000 };
|
|
144
|
+
|
|
145
|
+
// Which source backs each panel -- stated per mode, because the answer genuinely
|
|
146
|
+
// differs. Every claim below is a measurement from 2026-09-08, and the RPC-only
|
|
147
|
+
// column is build-dependent in a way that no RPC call can tell you: the build
|
|
148
|
+
// that published 2,116,236,872 bytes and the build that published 0 both report
|
|
149
|
+
// the same subversion string as each other.
|
|
150
|
+
function sourcesFor(logEnabled) {
|
|
151
|
+
const logOnly = 'no RPC source for this; it exists only in the node log';
|
|
152
|
+
return [
|
|
153
|
+
{ panel: 'sync bar', source: 'getblockchaininfo blocks/headers + verificationprogress', note: 'kept as two separate figures (rule 9)' },
|
|
154
|
+
logEnabled
|
|
155
|
+
? { panel: 'bandwidth', source: 'node log [dlc] tick lines', note: 'the deployed build answers getnettotals 0/0; the 03:02 bench build answers real totals (11.56 MB/s by delta against 11.2 MB/s stated in its own log)' }
|
|
156
|
+
: { panel: 'bandwidth', source: 'getnettotals delta rate (RPC-only mode)', note: 'works when the counters move. Measured 2026-09-09: the production daemon read 0/0 at 09:36 and 23,955,131 bytes received at 17:36 with no restart in between, so this row is a question to re-ask, not an answer to remember (MEASUREMENTS 23). When the counter is flat the panel is empty, never 0 B/s' },
|
|
157
|
+
logEnabled
|
|
158
|
+
? { panel: 'peer identity', source: 'node log (relay legs, worker lines, connects)', note: 'getpeerinfo returns [] on the deployed build; the 03:02 bench build answers 21 rows with per-peer bytes' }
|
|
159
|
+
: { panel: 'peer identity', source: 'getpeerinfo (RPC-only mode, promoted to the 15 s tier)', note: '[] on the deployed build means no peer panel at all; on newer builds rows sum to 70.29% of getnettotals, so they are a subset, not a breakdown' },
|
|
160
|
+
logEnabled
|
|
161
|
+
? { panel: 'peer book', source: 'getaddrmaninfo (RPC) + log [dlc] discovery lines', note: 'production addrman measured: 52,877 tried (ipv4 36,482 / ipv6 9,046 / onion 6,304 / i2p 1,045)' }
|
|
162
|
+
: { panel: 'peer book', source: 'getaddrmaninfo (RPC)', note: 'the only peer-set figure RPC-only mode keeps; the log\'s "book now N" and "confirmed-live" counts are lost' },
|
|
163
|
+
logEnabled
|
|
164
|
+
? { panel: 'banned peers', source: 'node log [dlc] banned N/M', note: 'listbanned answered [] while the same node\'s log said banned 8/114 -- worker bans are not in the stored ban table' }
|
|
165
|
+
: { panel: 'banned peers', source: 'listbanned (stored ban table only)', note: 'the download worker\'s per-run bans have no RPC source; that count is unavailable, not zero' },
|
|
166
|
+
{ panel: 'disk write rate', source: logEnabled ? 'node log [dlc] write field' : 'none (RPC-only mode)', note: logEnabled ? 'getnettotals carries no write counter' : logOnly },
|
|
167
|
+
{ panel: 'mempool ingest + rejects', source: logEnabled ? 'node log [tx_accept] / [txrelay]' : 'none (RPC-only mode)', note: logEnabled ? 'pool counts also come from getmempoolinfo' : logOnly },
|
|
168
|
+
{ panel: 'mempool feerate', source: 'getrawmempool verbose (vsize, fees.base)', note: 'no depends/ancestorcount fields in this node\'s reply' },
|
|
169
|
+
{ panel: 'block stats', source: 'getblockstats', note: 'per-height fee, txs, total_size/total_weight and feerate percentiles. The size shown is total_size -- the sum of transaction sizes, not the serialized block (the 80-byte header and the txid-count varint are excluded), and the row carries that basis as sizeBasis. size/weight/strippedsize are getblock fields and are not statistics this endpoint has: asking for them by name returns 31 keys and none of them (MEASUREMENTS 24)' },
|
|
170
|
+
{ panel: 'node\'s own IBD eta', source: logEnabled ? 'node log [dlc] == / [utxo_live] catchup' : 'none (RPC-only mode)', note: 'kept separate from the monitor\'s measured rate, never merged (rule 4)' },
|
|
171
|
+
{ panel: 'validation stalls / archive holes', source: logEnabled ? 'node log [utxo_live] / [check]' : 'none (RPC-only mode)', note: logOnly },
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const routes = [
|
|
176
|
+
// ---------------------------------------------------------------- public
|
|
177
|
+
{
|
|
178
|
+
method: 'GET', path: '/api/build', auth: 'none', handler: async (ctx, app) => {
|
|
179
|
+
// Computed once per request: reporting one digest in `build` and answering
|
|
180
|
+
// `matchesClient` from a second one would be two answers to one question.
|
|
181
|
+
const live = await app.buildId();
|
|
182
|
+
return {
|
|
183
|
+
version: app.version,
|
|
184
|
+
// Live, not the value this process read at boot. The question this endpoint exists
|
|
185
|
+
// to answer is "is the code in my tab the code on disk?", and on a box that
|
|
186
|
+
// deploys by copying files over a running service -- which is this box, and the
|
|
187
|
+
// reason the id is a file digest rather than a git SHA -- the boot value stops
|
|
188
|
+
// being that answer the moment anyone edits public/. Editing one asset and
|
|
189
|
+
// leaving the service up made every page report "you are running an older
|
|
190
|
+
// build", because the page was stamped with the live digest while the API still
|
|
191
|
+
// quoted the boot one.
|
|
192
|
+
build: live,
|
|
193
|
+
bootBuild: app.build,
|
|
194
|
+
// The page sends what it was served with; the answer is whether they agree.
|
|
195
|
+
// This exists because "did my fix land?" cost fifteen minutes each time it was
|
|
196
|
+
// asked on 2026-09-08: an unversioned script behind a revalidating ETag is
|
|
197
|
+
// indistinguishable from a stale one from inside the tab.
|
|
198
|
+
matchesClient: ctx.query.build ? String(ctx.query.build) === live : null,
|
|
199
|
+
scheme: app.scheme,
|
|
200
|
+
tls: app.tls,
|
|
201
|
+
uptimeSec: Math.round((Date.now() - app.startedAt) / 1000),
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
// THE ABOUT PAGE'S HOST FACTS (operator, 2026-09-12: "system info. os info version").
|
|
206
|
+
//
|
|
207
|
+
// `auth: 'any'` rather than 'none' ON PURPOSE. With accounts off this is the same as open --
|
|
208
|
+
// that is the operator's posture, stated elsewhere -- but with accounts ON, the host's OS,
|
|
209
|
+
// processor and memory should not be readable before sign-in. /api/build and /api/health are
|
|
210
|
+
// 'none' because a version string answers "is my tab current", which a login page needs.
|
|
211
|
+
//
|
|
212
|
+
// What is deliberately NOT here: hostname, username, network addresses, environment. This
|
|
213
|
+
// monitor is open-access by default, so anything on this route is readable by anyone who can
|
|
214
|
+
// reach the port -- and those four are precisely what test/privacy.test.js exists to keep out
|
|
215
|
+
// of published artefacts. The OS and the processor identify a machine's SHAPE, not its owner.
|
|
216
|
+
{
|
|
217
|
+
method: 'GET', path: '/api/about', auth: 'any', handler: async (ctx, app) => {
|
|
218
|
+
const os = await import('node:os');
|
|
219
|
+
const cpus = os.cpus() ?? [];
|
|
220
|
+
return {
|
|
221
|
+
version: app.version,
|
|
222
|
+
build: await app.buildId(),
|
|
223
|
+
platform: os.platform(),
|
|
224
|
+
release: os.release(),
|
|
225
|
+
arch: os.arch(),
|
|
226
|
+
cpus: cpus.length || null,
|
|
227
|
+
cpuModel: cpus[0]?.model?.trim() ?? null,
|
|
228
|
+
totalMemGb: Math.round((os.totalmem() / 1e9) * 10) / 10,
|
|
229
|
+
node: process.version,
|
|
230
|
+
uptimeSec: Math.round((Date.now() - app.startedAt) / 1000),
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
method: 'GET', path: '/api/health', auth: 'none', handler: (ctx, app) => {
|
|
236
|
+
const nodes = [...app.monitors.values()].map((m) => ({
|
|
237
|
+
id: m.id, label: m.label, online: m.rpc.telemetry().online,
|
|
238
|
+
// Without this the `required` filter below sees `optional` undefined on
|
|
239
|
+
// every node, treats ALL of them as required, and the optional-node fix
|
|
240
|
+
// silently does nothing. Behaviour is asserted in
|
|
241
|
+
// test/health-semantics.test.js, not just pattern-matched.
|
|
242
|
+
optional: !!m.cfg.optional,
|
|
243
|
+
chain: m.state.chain, tip: m.state.chainInfo?.blocks ?? null,
|
|
244
|
+
lastError: m.state.lastError?.message ?? null,
|
|
245
|
+
}));
|
|
246
|
+
// What "ok" means, since uptime probes key on it. Requiring EVERY
|
|
247
|
+
// configured node to be up was wrong in a way I introduced myself: making
|
|
248
|
+
// the benchmark node a default meant that benchmark ending turned the whole
|
|
249
|
+
// app red ("ok": false) while it was monitoring its real node perfectly.
|
|
250
|
+
// An optional node that has gone away is a note, not a failure.
|
|
251
|
+
const required = nodes.filter((n) => !n.optional);
|
|
252
|
+
const degraded = nodes.filter((n) => !n.online).map((n) => n.id);
|
|
253
|
+
return {
|
|
254
|
+
ok: app.monitors.size > 0 && required.every((n) => n.online),
|
|
255
|
+
degraded,
|
|
256
|
+
version: app.version,
|
|
257
|
+
build: app.build,
|
|
258
|
+
scheme: app.scheme,
|
|
259
|
+
tls: app.tls,
|
|
260
|
+
uptimeSec: Math.round((Date.now() - app.startedAt) / 1000),
|
|
261
|
+
authRequired: app.cfg.auth.enabled,
|
|
262
|
+
nodes,
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
// ------------------------------------------------------------------ auth
|
|
268
|
+
{
|
|
269
|
+
method: 'POST', path: '/api/login', auth: 'none', body: true, csrf: false,
|
|
270
|
+
handler: async (ctx, app) => {
|
|
271
|
+
// Accounts off: do not run the KDF against a password nobody can own. This is
|
|
272
|
+
// also the honest answer — "accounts are disabled" beats "invalid username or
|
|
273
|
+
// password", which implies a credential exists to be wrong about.
|
|
274
|
+
if (!app.cfg.auth.enabled) {
|
|
275
|
+
throw new HttpError(403, 'accounts are disabled on this monitor; it is open without sign-in (start it with BLOCKYARD_AUTH=1 to require accounts)', { code: 'accounts_disabled' });
|
|
276
|
+
}
|
|
277
|
+
// Per-address throttle in front of the KDF. LoginGuard answers "this username
|
|
278
|
+
// keeps failing", and the per-user token bucket cannot apply here (there is no
|
|
279
|
+
// user yet), so before this a distributed grind -- many addresses, a few
|
|
280
|
+
// attempts each -- sat below every threshold the code had. It is also the only
|
|
281
|
+
// thing standing between a cheap flood and the scrypt KDF (~50 ms, ~16 MB per
|
|
282
|
+
// guess) running on the request thread until the box stops answering.
|
|
283
|
+
const rl = app.loginLimiter?.check(`login:${ctx.ip}`, 1);
|
|
284
|
+
if (rl && !rl.ok) {
|
|
285
|
+
await app.audit({ type: 'login-throttled', username: String(ctx.body?.username ?? '').toLowerCase().trim() || null, ip: ctx.ip, retryAfterMs: rl.retryAfterMs });
|
|
286
|
+
throw new HttpError(429, `too many login attempts from this address; retry in ${Math.ceil(rl.retryAfterMs / 1000)}s`, { code: 'throttled' });
|
|
287
|
+
}
|
|
288
|
+
const username = String(ctx.body?.username ?? '').toLowerCase().trim();
|
|
289
|
+
const password = String(ctx.body?.password ?? '');
|
|
290
|
+
if (!username || !password) throw new HttpError(400, 'username and password are both required');
|
|
291
|
+
const gate = app.guard.status(username, ctx.ip);
|
|
292
|
+
if (gate.blocked) {
|
|
293
|
+
throw new HttpError(429, `too many failed logins; retry in ${Math.ceil(gate.retryAfterMs / 1000)}s`, { code: 'locked' });
|
|
294
|
+
}
|
|
295
|
+
const result = await app.users.verify(username, password);
|
|
296
|
+
if (!result.ok) {
|
|
297
|
+
const lock = app.guard.noteFailure(username, ctx.ip);
|
|
298
|
+
await app.audit({ type: 'login', ok: false, username, ip: ctx.ip, reason: result.reason, locked: lock.locked.length > 0 });
|
|
299
|
+
// Same message for "no such user" and "wrong password": the account's
|
|
300
|
+
// existence is not information this endpoint hands out.
|
|
301
|
+
throw new HttpError(401, 'invalid username or password', { code: 'bad_credentials' });
|
|
302
|
+
}
|
|
303
|
+
if (result.upgraded) {
|
|
304
|
+
// Say it once, in the log and the audit trail rather than in the UI: an
|
|
305
|
+
// operator who raises auth.scrypt needs to know it took effect, and "it
|
|
306
|
+
// applies on the next login" is only checkable if that login reports.
|
|
307
|
+
app.log({ level: 'info', msg: `rehashed ${username}'s password at the configured cost (N ${result.upgraded.from.N} -> ${result.upgraded.to.N})` });
|
|
308
|
+
await app.audit({ type: 'kdf-upgrade', username, from: result.upgraded.from, to: result.upgraded.to, ip: ctx.ip });
|
|
309
|
+
}
|
|
310
|
+
app.guard.noteSuccess(username, ctx.ip);
|
|
311
|
+
const { token, session } = app.sessions.create(result.user, { ip: ctx.ip, userAgent: ctx.req.headers['user-agent'] });
|
|
312
|
+
await app.sessions.save().catch(() => {});
|
|
313
|
+
await app.audit({ type: 'login', ok: true, username, ip: ctx.ip });
|
|
314
|
+
ctx.setCookie(app.cfg.auth.cookieName, token, { maxAgeMs: app.cfg.auth.sessionTtlMs, secure: app.cfg.auth.secureCookie });
|
|
315
|
+
// Readable by JS on purpose: it is the CSRF double-submit value.
|
|
316
|
+
ctx.setCookie('blockyard_csrf', session.csrf, { httpOnly: false, maxAgeMs: app.cfg.auth.sessionTtlMs, secure: app.cfg.auth.secureCookie });
|
|
317
|
+
return { ok: true, user: publicUser(result.user), csrf: session.csrf };
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
method: 'POST', path: '/api/logout', auth: 'any', csrf: true,
|
|
322
|
+
handler: async (ctx, app) => {
|
|
323
|
+
// Nothing to end when there was never a session; answer honestly rather than
|
|
324
|
+
// writing an anonymous "logout" row into a trail that cannot attribute it.
|
|
325
|
+
if (!app.cfg.auth.enabled) return { ok: true, accounts: false, note: 'accounts are off, so there is no session to end' };
|
|
326
|
+
if (ctx.token) app.sessions.destroy(ctx.token);
|
|
327
|
+
await app.sessions.save().catch(() => {});
|
|
328
|
+
ctx.clearCookie(app.cfg.auth.cookieName);
|
|
329
|
+
ctx.clearCookie('blockyard_csrf');
|
|
330
|
+
await app.audit({ type: 'logout', ok: true, username: ctx.user.username, ip: ctx.ip });
|
|
331
|
+
return { ok: true };
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
method: 'GET', path: '/api/me', auth: 'any',
|
|
336
|
+
// Open mode is reported, not assumed: `accounts:false` is what the UI keys the
|
|
337
|
+
// header pill and the hidden sign-out button off, and `note` says the same thing
|
|
338
|
+
// in words so nobody has to infer the posture from a missing button.
|
|
339
|
+
handler: (ctx, app) => (ctx.user?.open ? {
|
|
340
|
+
user: publicUser(ctx.user),
|
|
341
|
+
accounts: false,
|
|
342
|
+
sessions: [],
|
|
343
|
+
note: 'no sign-in: this monitor is open to anyone who can reach it, read-only',
|
|
344
|
+
actions: visibleActions(app.cfg, ctx.user.role),
|
|
345
|
+
capabilities: {
|
|
346
|
+
canCallRpc: true,
|
|
347
|
+
canAct: false,
|
|
348
|
+
actionsEnabled: app.cfg.actions.enabled,
|
|
349
|
+
// Node writes stay refused in open mode unless the operator opted into
|
|
350
|
+
// exactly that, twice (config.actions.allowWritesWithoutAuth).
|
|
351
|
+
writesRequireAccounts: !app.cfg.actions.allowWritesWithoutAuth,
|
|
352
|
+
allowedActions: [],
|
|
353
|
+
ceiling: 'viewer',
|
|
354
|
+
},
|
|
355
|
+
} : {
|
|
356
|
+
user: publicUser(ctx.user),
|
|
357
|
+
accounts: true,
|
|
358
|
+
sessions: app.sessions.listFor(ctx.user.id).map((s) => ({ ...s, current: ctx.session && s.createdAt === ctx.session.createdAt })),
|
|
359
|
+
actions: visibleActions(app.cfg, ctx.user.role),
|
|
360
|
+
capabilities: {
|
|
361
|
+
canCallRpc: true,
|
|
362
|
+
canAct: app.cfg.actions.enabled && app.cfg.actions.allow.length > 0,
|
|
363
|
+
actionsEnabled: app.cfg.actions.enabled,
|
|
364
|
+
allowedActions: app.cfg.actions.allow,
|
|
365
|
+
ceiling: ctx.user.role,
|
|
366
|
+
},
|
|
367
|
+
}),
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
method: 'POST', path: '/api/logout-all', auth: 'any', csrf: true,
|
|
371
|
+
handler: async (ctx, app) => {
|
|
372
|
+
if (!app.cfg.auth.enabled) throw new HttpError(403, 'accounts are disabled (start with BLOCKYARD_AUTH=1 to enable them)');
|
|
373
|
+
const n = app.sessions.destroyForUser(ctx.user.id);
|
|
374
|
+
await app.sessions.save().catch(() => {});
|
|
375
|
+
ctx.clearCookie(app.cfg.auth.cookieName);
|
|
376
|
+
await app.audit({ type: 'logout-all', ok: true, username: ctx.user.username, sessions: n, ip: ctx.ip });
|
|
377
|
+
return { ok: true, revoked: n };
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
// -------------------------------------------------------------- read model
|
|
382
|
+
{ method: 'GET', path: '/api/state', auth: 'any', handler: (ctx, app) => fullState(ctx, app) },
|
|
383
|
+
{
|
|
384
|
+
// The sync bar's endpoint on its own: small enough to poll hard from a
|
|
385
|
+
// status widget without paying for the whole read model.
|
|
386
|
+
method: 'GET', path: '/api/sync', auth: 'any',
|
|
387
|
+
handler: (ctx, app) => {
|
|
388
|
+
const m = pickNode(ctx, app);
|
|
389
|
+
const s = m.snapshot({ seriesRanges: {} });
|
|
390
|
+
return { node: s.id, sync: s.sync, tip: s.tip, chain: s.chain, ibd: s.ibd, health: { rpc: { online: s.health.rpc.online, lastError: s.health.lastError } } };
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
{ method: 'GET', path: '/api/mempool', auth: 'any', handler: (ctx, app) => mempoolView(pickNode(ctx, app)) },
|
|
394
|
+
// Viewer Mode 2: every transaction in the next block's worth of the pool (monitor.js denseBlock)
|
|
395
|
+
{
|
|
396
|
+
method: 'GET', path: '/api/mempool/dense', auth: 'any',
|
|
397
|
+
handler: (ctx, app) => { const m = pickNode(ctx, app); return { node: m.id, ...(m.mempoolDense ?? { at: null, n: 0, v: [], r: [], id: [] }) }; },
|
|
398
|
+
},
|
|
399
|
+
{ method: 'GET', path: '/api/peers', auth: 'any', handler: (ctx, app) => peersView(pickNode(ctx, app)) },
|
|
400
|
+
{ method: 'GET', path: '/api/net', auth: 'any', handler: (ctx, app) => netView(pickNode(ctx, app)) },
|
|
401
|
+
{ method: 'GET', path: '/api/mining', auth: 'any', handler: (ctx, app) => ({ node: pickNode(ctx, app).id, ...pickNode(ctx, app).miningView() }) },
|
|
402
|
+
{
|
|
403
|
+
// The block being built right now, ASSEMBLED HERE from the verbose mempool the pool tier
|
|
404
|
+
// already reads (collect/gbt.js) -- no RPC call of its own. It used to be a getblocktemplate
|
|
405
|
+
// worth 1.3-1.5 s of the node's single RPC thread, which is why this was on-demand; what
|
|
406
|
+
// `stale` now bounds is re-assembly of the same pool, not a call to the node. The answer is
|
|
407
|
+
// exactly as fresh as the pool tier's last read, and says so (poolAgeMs).
|
|
408
|
+
method: 'GET', path: '/api/nextblock', auth: 'any',
|
|
409
|
+
handler: async (ctx, app) => {
|
|
410
|
+
const m = pickNode(ctx, app);
|
|
411
|
+
const stale = Number(ctx.query.stale ?? 15000);
|
|
412
|
+
const res = await m.fetchTemplate({ staleMs: Number.isFinite(stale) ? stale : 15000, force: ctx.query.refresh === '1' });
|
|
413
|
+
return { node: m.id, ...(res ?? { unavailable: 'no answer' }) };
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
method: 'GET', path: '/api/blocks', auth: 'any',
|
|
418
|
+
handler: (ctx, app) => {
|
|
419
|
+
const m = pickNode(ctx, app);
|
|
420
|
+
const limit = clampInt(ctx.query.limit, 1, 400, 90);
|
|
421
|
+
const s = m.snapshot({ seriesRanges: {} });
|
|
422
|
+
return { node: m.id, blocks: s.blocks.recent.slice(0, limit), stats: blockStats(s.blocks.recent) };
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
// ------------------------------------------------- block / tx drill-down
|
|
426
|
+
//
|
|
427
|
+
// "Which transaction?" used to be answerable only by typing an RPC call into the
|
|
428
|
+
// console, which is a shell, not a view. These two routes are read-only, go
|
|
429
|
+
// through the same serialized lane as everything else (rule 1), and are
|
|
430
|
+
// deliberately narrow about what they ask the node:
|
|
431
|
+
//
|
|
432
|
+
// * getblock verbosity=2 is never called. Measured 2026-09-08 (MEASUREMENTS §6):
|
|
433
|
+
// this node returns 11 MB of hex for one block at verbosity 2 AND omits the
|
|
434
|
+
// fee/deltafee fields Core includes, so the verbose form is simultaneously the
|
|
435
|
+
// slowest option and the least informative. verbosity=1 gives header + txids.
|
|
436
|
+
// * No transaction hex is ever returned. A caller who needs it is one
|
|
437
|
+
// /api/rpc getblock away; a dashboard proxying 11 MB per click is a denial of
|
|
438
|
+
// service against a single-threaded server, wearing a nice font.
|
|
439
|
+
{
|
|
440
|
+
method: 'GET', path: '/api/block', auth: 'any',
|
|
441
|
+
handler: async (ctx, app) => blockDrill(ctx, app),
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
method: 'GET', path: '/api/tx', auth: 'any',
|
|
445
|
+
handler: async (ctx, app) => txDrill(ctx, app),
|
|
446
|
+
},
|
|
447
|
+
// The explorer (server/http/explorer.js): block, transaction and address pages, each one
|
|
448
|
+
// batched lane turn (or two). They answer { ok: false, error, hint } for a bad query.
|
|
449
|
+
{ method: 'GET', path: '/api/x/search', auth: 'any', handler: (ctx, app) => xSearch(pickNode(ctx, app), ctx.query) },
|
|
450
|
+
{ method: 'GET', path: '/api/x/tx', auth: 'any', handler: async (ctx, app) => withUsd(app, await xTx(pickNode(ctx, app), ctx.query)) },
|
|
451
|
+
{ method: 'GET', path: '/api/x/block', auth: 'any', handler: async (ctx, app) => withUsd(app, await xBlock(pickNode(ctx, app), ctx.query)) },
|
|
452
|
+
{ method: 'GET', path: '/api/x/address', auth: 'any', handler: async (ctx, app) => withUsd(app, await xAddress(pickNode(ctx, app), ctx.query)) },
|
|
453
|
+
// Exchange prices (server/collect/markets.js). Asking is what keeps the feed polling.
|
|
454
|
+
{
|
|
455
|
+
method: 'GET', path: '/api/markets', auth: 'any',
|
|
456
|
+
handler: async (ctx, app) => {
|
|
457
|
+
if (!app.markets) return { ok: true, enabled: false, note: MARKETS_OFF };
|
|
458
|
+
if (!(await marketsPollingOn(app))) return pollingOff(app);
|
|
459
|
+
app.markets.touch();
|
|
460
|
+
return app.markets.view();
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
// THE SPOT PRICE, for dollar figures on pages that are not Markets (the Mining tab's reward
|
|
464
|
+
// stats, 2026-09-15: "Add dollar figures"): the feed's median while it is polling, else the
|
|
465
|
+
// explorer's cached spot read (two exchanges, at most once a minute) -- and null, saying why,
|
|
466
|
+
// while market polling is off. Does NOT touch the feed: asking the price here never starts
|
|
467
|
+
// the week-long polling that Markets does.
|
|
468
|
+
{
|
|
469
|
+
method: 'GET', path: '/api/price', auth: 'any',
|
|
470
|
+
handler: async (ctx, app) => {
|
|
471
|
+
if (!app.markets) return { ok: true, usd: null, enabled: false, note: MARKETS_OFF };
|
|
472
|
+
if (!(await marketsPollingOn(app))) return { ok: true, usd: null, polling: false, note: POLLING_OFF };
|
|
473
|
+
const p = await Promise.race([app.markets.spot().catch(() => null), new Promise((res) => { setTimeout(res, 1500, null).unref?.(); })]);
|
|
474
|
+
return { ok: true, usd: p?.usd ?? null, at: p?.at ?? null, source: p?.source ?? null };
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
// The depth chart: the books as cumulative depth, and the snapshot `ago` seconds earlier.
|
|
478
|
+
{
|
|
479
|
+
method: 'GET', path: '/api/markets/depth', auth: 'any',
|
|
480
|
+
handler: async (ctx, app) => {
|
|
481
|
+
if (!app.markets) return { ok: true, enabled: false, note: MARKETS_OFF };
|
|
482
|
+
if (!(await marketsPollingOn(app))) return pollingOff(app);
|
|
483
|
+
app.markets.touch();
|
|
484
|
+
return app.markets.depthView(Number(ctx.query.ago) || 600);
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
method: 'GET', path: '/api/events', auth: 'any',
|
|
489
|
+
handler: (ctx, app) => {
|
|
490
|
+
const since = ctx.query.since != null ? Number(ctx.query.since) : 0;
|
|
491
|
+
const limit = clampInt(ctx.query.limit, 1, 1000, 200);
|
|
492
|
+
// Default: what this monitor observed and decided. Node log lines are a separate
|
|
493
|
+
// source and are not shown as panels any more; ?source=all opts back in for a
|
|
494
|
+
// person debugging the parser itself.
|
|
495
|
+
const src = ctx.query.source ? String(ctx.query.source).split(',') : ['monitor'];
|
|
496
|
+
const sev = ctx.query.severity ? String(ctx.query.severity).split(',') : null;
|
|
497
|
+
const kinds = ctx.query.kind ? String(ctx.query.kind).split(',') : null;
|
|
498
|
+
const q = ctx.query.q ? String(ctx.query.q).toLowerCase() : null;
|
|
499
|
+
let rows = app.history.eventsSinceSeq(since, limit * 4);
|
|
500
|
+
if (!src.includes('all')) rows = rows.filter((r) => src.includes(r.source ?? 'monitor'));
|
|
501
|
+
if (sev) rows = rows.filter((r) => sev.includes(r.severity));
|
|
502
|
+
if (kinds) rows = rows.filter((r) => kinds.includes(r.kind));
|
|
503
|
+
if (q) rows = rows.filter((r) => `${r.text ?? ''} ${r.tag ?? ''} ${r.kind ?? ''}`.toLowerCase().includes(q));
|
|
504
|
+
return { events: rows.slice(0, limit), maxSeq: app.history.eventsSeq, count: rows.length };
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
method: 'GET', path: '/api/series', auth: 'any',
|
|
509
|
+
handler: (ctx, app) => {
|
|
510
|
+
const m = pickNode(ctx, app);
|
|
511
|
+
const names = (ctx.query.name ? String(ctx.query.name).split(',') : ['mempool']);
|
|
512
|
+
const range = parseRange(ctx.query.range ?? ctx.query.since, 3600_000);
|
|
513
|
+
const points = clampInt(ctx.query.points, 20, 2000, 240);
|
|
514
|
+
const bucketMs = Math.max(1000, Math.round(range / points / 1000) * 1000);
|
|
515
|
+
const out = { node: m.id, rangeMs: range, bucketMs, series: {} };
|
|
516
|
+
for (const name of names) {
|
|
517
|
+
const fields = SERIES[name];
|
|
518
|
+
if (!fields) throw new HttpError(400, `unknown series "${name}"; known: ${Object.keys(SERIES).join(', ')}`);
|
|
519
|
+
const wantFields = ctx.query.field ? String(ctx.query.field).split(',') : fields.filter((f) => f !== 't');
|
|
520
|
+
const bad = wantFields.filter((f) => !fields.includes(f));
|
|
521
|
+
if (bad.length) throw new HttpError(400, `field(s) ${bad.join(', ')} not in series "${name}"; known: ${fields.join(', ')}`);
|
|
522
|
+
const ring = app.history.forNode(m.id).ring(name);
|
|
523
|
+
out.series[name] = Object.fromEntries(wantFields.map((f) => [
|
|
524
|
+
f, ring.series(f, { since: Date.now() - range, bucketMs, agg: ctx.query.agg ?? 'last' }),
|
|
525
|
+
]));
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
// Every configured node with its sync state, so the UI can say WHICH node is
|
|
532
|
+
// which. A single-node deployment that reports "Synced 100%" while a second
|
|
533
|
+
// node three directories away is 72% through an IBD is not lying, but it is
|
|
534
|
+
// not useful either -- and it is exactly what happened on this box.
|
|
535
|
+
method: 'GET', path: '/api/nodes', auth: 'any',
|
|
536
|
+
handler: (ctx, app) => ({
|
|
537
|
+
nodes: [...app.monitors.values()].map((m) => {
|
|
538
|
+
let sync = null;
|
|
539
|
+
try { sync = m.snapshot({ seriesRanges: {} }).sync; } catch { /* not yet populated */ }
|
|
540
|
+
return {
|
|
541
|
+
id: m.id, label: m.label, color: m.color, rpcUrl: m.node?.rpcUrl ?? m.rpc.url,
|
|
542
|
+
chain: m.state.chain, online: m.rpc.telemetry().online,
|
|
543
|
+
optional: !!m.cfg.optional,
|
|
544
|
+
syncState: sync?.state ?? null, pct: sync?.pct ?? null,
|
|
545
|
+
height: sync?.height ?? null, headers: sync?.headers ?? null,
|
|
546
|
+
syncing: !!sync && sync.state !== 'synced' && sync.state !== 'unknown',
|
|
547
|
+
};
|
|
548
|
+
}),
|
|
549
|
+
primary: app.primary?.id ?? null,
|
|
550
|
+
// Any node needing attention, so a default landing page lands on the work
|
|
551
|
+
// rather than on the node that has none.
|
|
552
|
+
attention: [...app.monitors.values()].map((m) => {
|
|
553
|
+
try {
|
|
554
|
+
const sy = m.snapshot({ seriesRanges: {} }).sync;
|
|
555
|
+
// Unknown counts as attention: a node we cannot read is exactly the
|
|
556
|
+
// thing to land on, not something to hide behind a synced sibling.
|
|
557
|
+
return sy && sy.state !== 'synced' ? m.id : null;
|
|
558
|
+
} catch { return null; }
|
|
559
|
+
}).filter(Boolean),
|
|
560
|
+
}),
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
method: 'GET', path: '/api/telemetry', auth: 'any',
|
|
564
|
+
handler: async (ctx, app) => ({
|
|
565
|
+
self: app.selfTelemetry(),
|
|
566
|
+
nodes: [...app.monitors.values()].map((m) => ({
|
|
567
|
+
id: m.id,
|
|
568
|
+
rpc: m.rpc.telemetry(),
|
|
569
|
+
log: m.tail ? m.tail.status() : { exists: false },
|
|
570
|
+
tiers: m.state.tierRunAt,
|
|
571
|
+
lastTier: m.tierStats ?? null,
|
|
572
|
+
history: app.history.summary(),
|
|
573
|
+
})),
|
|
574
|
+
sse: app.hub.stats(),
|
|
575
|
+
audit: await app.auditLog.stats(),
|
|
576
|
+
}),
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
method: 'GET', path: '/api/config', auth: 'any',
|
|
580
|
+
handler: (ctx, app) => ({
|
|
581
|
+
poll: app.cfg.poll,
|
|
582
|
+
rpc: { maxInFlight: app.cfg.rpc.maxInFlight, minIntervalMs: app.cfg.rpc.minIntervalMs, maxRatePerSec: app.cfg.rpc.maxRatePerSec, timeoutMs: app.cfg.rpc.timeoutMs },
|
|
583
|
+
allowlist: allowlistSummary(),
|
|
584
|
+
actions: { enabled: app.cfg.actions.enabled, allow: app.cfg.actions.allow, requireAdmin: app.cfg.requireAdmin },
|
|
585
|
+
retention: { hours: app.cfg.store.retentionHours, ringCapacity: app.cfg.store.ringCapacity, events: app.cfg.store.maxEventLog },
|
|
586
|
+
// Access posture, so the UI can state it rather than infer it from which
|
|
587
|
+
// buttons happen to be hidden.
|
|
588
|
+
access: app.cfg.auth.enabled
|
|
589
|
+
? { mode: 'accounts', anonymous: false }
|
|
590
|
+
: { mode: 'open', anonymous: true, role: 'viewer', writesAllowed: app.cfg.actions.allowWritesWithoutAuth },
|
|
591
|
+
// Which source backs each panel, stated rather than implied.
|
|
592
|
+
// The posture itself, so the UI and the smoke suite can assert against the
|
|
593
|
+
// source table instead of a remembered string: which sources are in effect is a
|
|
594
|
+
// fact about this deployment, not a constant.
|
|
595
|
+
log: { enabled: app.cfg.log.enabled === true },
|
|
596
|
+
sources: sourcesFor(app.cfg.log.enabled),
|
|
597
|
+
}),
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
// ------------------------------------------------------- RPC passthrough
|
|
601
|
+
{
|
|
602
|
+
method: 'POST', path: '/api/rpc', auth: 'any', csrf: true, body: true,
|
|
603
|
+
handler: async (ctx, app) => {
|
|
604
|
+
const m = pickNode(ctx, app);
|
|
605
|
+
const method = String(ctx.body?.method ?? '');
|
|
606
|
+
const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
|
|
607
|
+
const cls = classifyMethod(method);
|
|
608
|
+
if (!cls.allowed) {
|
|
609
|
+
await app.audit({ type: 'rpc-denied', username: ctx.user.username, node: m.id, method, reason: cls.reason, ip: ctx.ip });
|
|
610
|
+
throw new HttpError(403, `${method || '(empty)'} is not callable from the web UI: ${cls.reason}`, { code: 'rpc_denied' });
|
|
611
|
+
}
|
|
612
|
+
const t0 = Date.now();
|
|
613
|
+
try {
|
|
614
|
+
const result = await m.rpc.call(method, params, {});
|
|
615
|
+
await app.audit({ type: 'rpc', ok: true, username: ctx.user.username, node: m.id, method, ms: Date.now() - t0, ip: ctx.ip });
|
|
616
|
+
return {
|
|
617
|
+
ok: true, node: m.id, method, ms: Date.now() - t0, result,
|
|
618
|
+
note: NODE_REFUSES.has(method) ? 'the node documents this method as refused or worker-owned; an error here is expected behaviour, not a monitor fault' : null,
|
|
619
|
+
};
|
|
620
|
+
} catch (err) {
|
|
621
|
+
await app.audit({ type: 'rpc', ok: false, username: ctx.user.username, node: m.id, method, error: err.message, ip: ctx.ip });
|
|
622
|
+
return { ok: false, node: m.id, method, ms: Date.now() - t0, error: { message: err.message, code: err.code ?? null, kind: err.kind ?? 'rpc' } };
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
// ------------------------------------------------------------- actions
|
|
628
|
+
{
|
|
629
|
+
method: 'GET', path: '/api/actions', auth: 'any',
|
|
630
|
+
handler: (ctx, app) => ({ enabled: app.cfg.actions.enabled, allowed: app.cfg.actions.allow, actions: visibleActions(app.cfg, ctx.user.role) }),
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
method: 'POST', path: '/api/action', auth: 'any', csrf: true, body: true,
|
|
634
|
+
handler: async (ctx, app) => {
|
|
635
|
+
const name = String(ctx.body?.action ?? '');
|
|
636
|
+
const m = pickNode(ctx, app);
|
|
637
|
+
const gate = actionAllowed(app.cfg, name, ctx.user.role);
|
|
638
|
+
if (!gate.ok) {
|
|
639
|
+
await app.audit({ type: 'action-denied', username: ctx.user.username, node: m.id, action: name, reason: gate.reason, ip: ctx.ip });
|
|
640
|
+
throw new HttpError(403, `action "${name}" not permitted: ${gate.reason}`, { code: 'action_denied' });
|
|
641
|
+
}
|
|
642
|
+
if (ctx.body?.confirm !== name) {
|
|
643
|
+
// A typed confirmation of the action name, so a stray click cannot fire.
|
|
644
|
+
throw new HttpError(400, `pass confirm:"${name}" to run this action`, { code: 'confirm_required' });
|
|
645
|
+
}
|
|
646
|
+
// Belt and braces with the config-load guard: a node write arriving over an
|
|
647
|
+
// unauthenticated socket fails even if that check is ever loosened.
|
|
648
|
+
if (!app.cfg.auth.enabled && !app.cfg.actions.allowWritesWithoutAuth) {
|
|
649
|
+
await app.audit({ type: 'action-denied', username: ctx.user.username, node: m.id, action: name, reason: 'writes disabled while accounts are off', ip: ctx.ip });
|
|
650
|
+
throw new HttpError(403, `action "${name}" refused: accounts are off, so this request carries no identity to hold accountable`, { code: 'action_denied' });
|
|
651
|
+
}
|
|
652
|
+
const def = gate.def;
|
|
653
|
+
const params = def.fixed ?? normaliseArgs(def.args, ctx.body?.args);
|
|
654
|
+
if (def.fixed && def.args.length) params.push(...normaliseArgs(def.args.slice(def.fixed.length), ctx.body?.args));
|
|
655
|
+
try {
|
|
656
|
+
const result = await m.rpc.call(def.method, params, {});
|
|
657
|
+
await app.audit({ type: 'action', ok: true, username: ctx.user.username, node: m.id, action: name, method: def.method, ip: ctx.ip, resultPreview: preview(result) });
|
|
658
|
+
return { ok: true, action: name, method: def.method, result };
|
|
659
|
+
} catch (err) {
|
|
660
|
+
await app.audit({ type: 'action', ok: false, username: ctx.user.username, node: m.id, action: name, error: err.message, ip: ctx.ip });
|
|
661
|
+
return { ok: false, action: name, method: def.method, error: { message: err.message, code: err.code ?? null } };
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
// --------------------------------------------------------------- admin
|
|
667
|
+
{ method: 'GET', path: '/api/users', auth: 'admin', handler: (ctx, app) => ({ users: app.users.list(), roles: ['viewer', 'operator', 'admin'] }) },
|
|
668
|
+
{
|
|
669
|
+
method: 'POST', path: '/api/users', auth: 'admin', csrf: true, body: true,
|
|
670
|
+
handler: async (ctx, app) => {
|
|
671
|
+
const { username, password, role } = ctx.body ?? {};
|
|
672
|
+
try {
|
|
673
|
+
const created = await app.users.createUser(username, password, { role: role ?? 'viewer' });
|
|
674
|
+
await app.audit({ type: 'user-create', username: ctx.user.username, target: created.username, role: created.role, ip: ctx.ip });
|
|
675
|
+
return { ok: true, user: created };
|
|
676
|
+
} catch (err) {
|
|
677
|
+
throw new HttpError(400, err.message);
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
// Creates a user with a generated password shown exactly once. There is no
|
|
683
|
+
// email to send a reset link to on a LAN box, so "generate and hand it over"
|
|
684
|
+
// is the honest equivalent -- and never storing it in plaintext is the price.
|
|
685
|
+
method: 'POST', path: '/api/users/generate', auth: 'admin', csrf: true, body: true,
|
|
686
|
+
handler: async (ctx, app) => {
|
|
687
|
+
const uname = String(ctx.body?.username ?? '').toLowerCase().trim();
|
|
688
|
+
const role = ctx.body?.role ?? 'viewer';
|
|
689
|
+
const pw = randomPassword(18);
|
|
690
|
+
try {
|
|
691
|
+
const created = await app.users.createUser(uname, pw, { role });
|
|
692
|
+
await app.audit({ type: 'user-create', username: ctx.user.username, target: created.username, role: created.role, generated: true, ip: ctx.ip });
|
|
693
|
+
return { ok: true, user: created, password: pw, warning: 'this password is shown once and is not stored in recoverable form' };
|
|
694
|
+
} catch (err) {
|
|
695
|
+
throw new HttpError(400, err.message);
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
method: 'POST', path: '/api/users/:username/role', auth: 'admin', csrf: true, body: true,
|
|
701
|
+
handler: async (ctx, app) => {
|
|
702
|
+
try {
|
|
703
|
+
const r = await app.users.setRole(ctx.params.username, ctx.body?.role);
|
|
704
|
+
app.sessions.destroyForUser(app.users.find(ctx.params.username)?.id);
|
|
705
|
+
await app.sessions.save().catch(() => {});
|
|
706
|
+
await app.audit({ type: 'user-role', username: ctx.user.username, target: r.username, role: r.role, ip: ctx.ip });
|
|
707
|
+
return { ok: true, user: r };
|
|
708
|
+
} catch (err) { throw new HttpError(400, err.message); }
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
method: 'POST', path: '/api/users/:username/disabled', auth: 'admin', csrf: true, body: true,
|
|
713
|
+
handler: async (ctx, app) => {
|
|
714
|
+
try {
|
|
715
|
+
const r = await app.users.setDisabled(ctx.params.username, !!ctx.body?.disabled);
|
|
716
|
+
if (r.disabled) {
|
|
717
|
+
app.sessions.destroyForUser(app.users.find(ctx.params.username)?.id);
|
|
718
|
+
await app.sessions.save().catch(() => {});
|
|
719
|
+
}
|
|
720
|
+
await app.audit({ type: 'user-disabled', username: ctx.user.username, target: r.username, disabled: r.disabled, ip: ctx.ip });
|
|
721
|
+
return { ok: true, user: r };
|
|
722
|
+
} catch (err) { throw new HttpError(400, err.message); }
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
method: 'POST', path: '/api/password', auth: 'any', csrf: true, body: true,
|
|
727
|
+
handler: async (ctx, app) => {
|
|
728
|
+
if (!app.cfg.auth.enabled) throw new HttpError(403, 'accounts are disabled, so there are no passwords to change (start with BLOCKYARD_AUTH=1)');
|
|
729
|
+
const current = String(ctx.body?.current ?? '');
|
|
730
|
+
const next = String(ctx.body?.password ?? '');
|
|
731
|
+
const who = ctx.user.role === 'admin' && ctx.body?.username ? String(ctx.body.username) : ctx.user.username;
|
|
732
|
+
if (who !== ctx.user.username) needRole(ctx, 'admin');
|
|
733
|
+
else {
|
|
734
|
+
const check = await app.users.verify(ctx.user.username, current);
|
|
735
|
+
if (!check.ok) throw new HttpError(403, 'current password is incorrect');
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
await app.users.setPassword(who, next);
|
|
739
|
+
app.sessions.destroyForUser(app.users.find(who)?.id);
|
|
740
|
+
await app.sessions.save().catch(() => {});
|
|
741
|
+
ctx.clearCookie(app.cfg.auth.cookieName);
|
|
742
|
+
await app.audit({ type: 'password-change', username: ctx.user.username, target: who, ip: ctx.ip });
|
|
743
|
+
return { ok: true, signedOut: true, note: 'sessions revoked; sign in again with the new password' };
|
|
744
|
+
} catch (err) { throw new HttpError(400, err.message); }
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
// The audit trail names who did what, which only means something if there is a
|
|
749
|
+
// who. With accounts off every entry would read "anonymous", so the endpoint
|
|
750
|
+
// says so instead of serving a trail that cannot attribute anything.
|
|
751
|
+
method: 'GET', path: '/api/audit', auth: 'admin',
|
|
752
|
+
handler: async (ctx, app) => {
|
|
753
|
+
if (!app.cfg.auth.enabled) {
|
|
754
|
+
return { entries: [], disabled: true, note: 'accounts are off, so audit entries could name nobody; start with BLOCKYARD_AUTH=1 to record per-user activity' };
|
|
755
|
+
}
|
|
756
|
+
const limit = clampInt(ctx.query.limit, 1, 500, 100);
|
|
757
|
+
// `log` is the state of the audit file itself. An audit trail that silently
|
|
758
|
+
// stopped rotating, or failed to rotate, is a disk-usage incident in progress
|
|
759
|
+
// -- and an audit trail nobody can see the size of is a lie waiting to happen.
|
|
760
|
+
return { entries: await app.readAudit(limit), limit, log: await app.auditLog.stats() };
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
|
|
764
|
+
// ------------------------------------------------------ node connection
|
|
765
|
+
// (operator, 2026-09-12: "Still left to do is a config connection in the web settings. We have no
|
|
766
|
+
// way for users to configure a connection to their rpc backend".)
|
|
767
|
+
//
|
|
768
|
+
// TWO ROUTES, DELIBERATELY. Testing a connection and committing it are different acts: the test
|
|
769
|
+
// writes nothing at all, and the save exists so a working answer can be kept. That is the
|
|
770
|
+
// operator's own ordering -- "We should only install the systemd after confirming a working
|
|
771
|
+
// connection to the server and everything works."
|
|
772
|
+
{
|
|
773
|
+
method: 'POST', path: '/api/config/node/test', auth: 'any', csrf: true, body: true,
|
|
774
|
+
handler: async (ctx, app) => {
|
|
775
|
+
configWriteAllowed(app, ctx);
|
|
776
|
+
const node = candidateNode(app, ctx.body);
|
|
777
|
+
const started = Date.now();
|
|
778
|
+
// A THROWAWAY CLIENT WITH ITS OWN LANE. The live node's client holds a serialized queue
|
|
779
|
+
// against a single-threaded RPC server; probing somewhere else must not take a slot in it.
|
|
780
|
+
//
|
|
781
|
+
// CREDENTIALS GO TO ONE ENDPOINT ONLY: the one this monitor is already configured for.
|
|
782
|
+
//
|
|
783
|
+
// This route used to build the probe as `{ ...app.cfg.nodes[0], ...node }`, so it inherited
|
|
784
|
+
// the live node's datadir and resolveCookie() read the real .cookie -- which the client then
|
|
785
|
+
// sent as an Authorization header TO WHATEVER URL THE REQUEST NAMED. With accounts off (the
|
|
786
|
+
// shipped default) the route needs no session, and the CSRF check is skipped without one, so
|
|
787
|
+
// a single unauthenticated POST -- including a plain cross-site HTML form, since readBody
|
|
788
|
+
// accepts x-www-form-urlencoded -- moved the node's RPC credential to any address the caller
|
|
789
|
+
// chose. Confirmed with a working proof of concept on 2026-09-13 against a planted cookie:
|
|
790
|
+
// the collector received it as `Authorization: Basic <the cookie>`.
|
|
791
|
+
//
|
|
792
|
+
// Dropping the spread alone does NOT fix it: candidateNode falls back to the configured
|
|
793
|
+
// datadir, so a request naming only an rpcUrl would still resolve the real cookie. The rule
|
|
794
|
+
// has to be about the DESTINATION. Same endpoint: authenticate as we already do. Any other
|
|
795
|
+
// endpoint: no datadir, no cookieFile, no rpcUser -- resolveCookie returns null and the
|
|
796
|
+
// client sends no Authorization header at all.
|
|
797
|
+
const cur = app.cfg.nodes?.[0] ?? {};
|
|
798
|
+
const sameEndpoint = !!cur.rpcUrl && node.rpcUrl === cur.rpcUrl;
|
|
799
|
+
const probeNode = sameEndpoint
|
|
800
|
+
? { ...cur, ...node, id: 'probe' }
|
|
801
|
+
: { rpcUrl: node.rpcUrl, chainHint: node.chainHint, id: 'probe' };
|
|
802
|
+
const probe = new RpcClient(probeNode,
|
|
803
|
+
{ ...app.cfg.rpc, timeoutMs: Math.min(app.cfg.rpc.timeoutMs ?? 8000, 8000) }, { log: () => {} });
|
|
804
|
+
try {
|
|
805
|
+
const info = await probe.call('getblockchaininfo', []);
|
|
806
|
+
return {
|
|
807
|
+
ok: true, ms: Date.now() - started, authenticated: sameEndpoint,
|
|
808
|
+
chain: info?.chain ?? null, blocks: info?.blocks ?? null,
|
|
809
|
+
ibd: info?.initialblockdownload ?? null,
|
|
810
|
+
};
|
|
811
|
+
} catch (err) {
|
|
812
|
+
// A 401 from a NEW endpoint is the expected answer, not a fault: it proves the address is
|
|
813
|
+
// an RPC server, which is what the form needs to know. Saying so beats reporting a
|
|
814
|
+
// mysterious auth failure for a credential we deliberately did not send.
|
|
815
|
+
if (!sameEndpoint && (err.kind === 'auth' || /\b401\b/.test(err.message ?? ''))) {
|
|
816
|
+
return {
|
|
817
|
+
ok: true, ms: Date.now() - started, authenticated: false, reachable: true,
|
|
818
|
+
chain: null, blocks: null, ibd: null,
|
|
819
|
+
note: 'the endpoint answered, and refused an unauthenticated call -- which is what an RPC server should do. '
|
|
820
|
+
+ 'Credentials are only sent to the endpoint this monitor is already configured for, so authentication was not tested. '
|
|
821
|
+
+ 'Save this connection and the monitor will use the datadir cookie for it.',
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
// A failed probe is an ANSWER, not a server error: the form needs the reason to show it.
|
|
825
|
+
// `authenticated` is reported on EVERY path, success or failure: a caller cannot otherwise
|
|
826
|
+
// tell "it refused us" from "we deliberately sent no credential", and those mean different
|
|
827
|
+
// things to someone deciding whether the connection they typed is right.
|
|
828
|
+
return {
|
|
829
|
+
ok: false, ms: Date.now() - started, authenticated: sameEndpoint,
|
|
830
|
+
error: { message: err.message, kind: err.kind ?? null, code: err.code ?? null },
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
method: 'POST', path: '/api/config/node', auth: 'any', csrf: true, body: true,
|
|
837
|
+
handler: async (ctx, app) => {
|
|
838
|
+
configWriteAllowed(app, ctx);
|
|
839
|
+
if (!app.configFile) {
|
|
840
|
+
throw new HttpError(409, 'this process was started without a config file (BLOCKYARD_CONFIG=none), so there is nowhere to save to', { code: 'no_config_file' });
|
|
841
|
+
}
|
|
842
|
+
if (ctx.body?.confirm !== 'save') throw new HttpError(400, 'pass confirm:"save" to write the configuration', { code: 'confirm_required' });
|
|
843
|
+
const node = candidateNode(app, ctx.body);
|
|
844
|
+
|
|
845
|
+
// Merge into whatever the file already says, so keys this form does not own survive.
|
|
846
|
+
let fileCfg = {};
|
|
847
|
+
try { fileCfg = JSON.parse(await fsp.readFile(app.configFile, 'utf8')); } catch { fileCfg = {}; }
|
|
848
|
+
const nodes = Array.isArray(fileCfg.nodes) && fileCfg.nodes.length ? fileCfg.nodes.slice() : [];
|
|
849
|
+
// UNDEFINED VALUES ARE NOT CHANGES. A key present with an undefined value still spreads, and
|
|
850
|
+
// JSON.stringify then omits it -- so carrying `{...cur}` across could DELETE a field from the
|
|
851
|
+
// file rather than preserve it. Only real values take part in the merge.
|
|
852
|
+
const changes = Object.fromEntries(Object.entries(node).filter(([, v]) => v !== undefined));
|
|
853
|
+
nodes[0] = { ...(nodes[0] ?? {}), ...changes };
|
|
854
|
+
delete nodes[0].__urlOverridden;
|
|
855
|
+
const next = { ...fileCfg, nodes };
|
|
856
|
+
|
|
857
|
+
// tmp + fsync + rename: a reader sees the old file or the new one, never a half-written one.
|
|
858
|
+
await fsp.mkdir(path.dirname(app.configFile), { recursive: true });
|
|
859
|
+
const tmp = `${app.configFile}.tmp`;
|
|
860
|
+
const fh = await fsp.open(tmp, 'w', 0o600);
|
|
861
|
+
await fh.writeFile(`${JSON.stringify(next, null, 2)}\n`);
|
|
862
|
+
await fh.sync();
|
|
863
|
+
await fh.close();
|
|
864
|
+
await fsp.rename(tmp, app.configFile);
|
|
865
|
+
|
|
866
|
+
// THE HONEST PART. The environment is applied AFTER the file is merged (config.js), so on a
|
|
867
|
+
// box whose unit sets BLOCKYARD_NODE_URL the file is written and then overridden. Saying
|
|
868
|
+
// "saved" without saying that would be a lie the operator only discovers after a restart.
|
|
869
|
+
const envOverrides = ['BLOCKYARD_NODE_URL', 'BLOCKYARD_DATADIR', 'BLOCKYARD_LOGFILE', 'BLOCKYARD_COOKIE', 'BLOCKYARD_NODE_LABEL']
|
|
870
|
+
.filter((k) => process.env[k] !== undefined && process.env[k] !== '');
|
|
871
|
+
await app.audit({ type: 'config-node', username: ctx.user.username, ip: ctx.ip, rpcUrl: node.rpcUrl, file: app.configFile });
|
|
872
|
+
return {
|
|
873
|
+
ok: true, file: app.configFile, restartRequired: true, envOverrides,
|
|
874
|
+
note: envOverrides.length
|
|
875
|
+
? `saved, but this process takes its node from ${envOverrides.join(', ')}, which the environment sets and which beats the file — change the unit or drop-in, or the restart will keep the old endpoint`
|
|
876
|
+
: 'saved; restart the monitor for it to take effect',
|
|
877
|
+
};
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
// ---------------------------------------------------------- display settings
|
|
882
|
+
// (operator, 2026-09-13: "This is a server app. Should store things on a server", of the display
|
|
883
|
+
// settings that until now lived in each browser's localStorage.)
|
|
884
|
+
//
|
|
885
|
+
// They were per-browser by an earlier decision -- "a kiosk screen and a laptop looking at the
|
|
886
|
+
// same monitor want different answers, and neither should need an account to have one". The cost
|
|
887
|
+
// of that was the one the operator hit: the settings existed nowhere the app could read, so they
|
|
888
|
+
// could not be backed up, shared between machines, or even looked at from the server.
|
|
889
|
+
//
|
|
890
|
+
// THE SERVER KEEPS THE BLOB AND NOTHING ELSE. It does not know the schema and must not grow one:
|
|
891
|
+
// public/js/settings.js normalise() clamps every value on the way in, so a hand-edited file
|
|
892
|
+
// cannot put the UI into a state the panel could not. What the server owes is durability, a size
|
|
893
|
+
// limit, and the same gate as every other config write.
|
|
894
|
+
{
|
|
895
|
+
method: 'GET', path: '/api/settings', auth: 'any',
|
|
896
|
+
handler: async (ctx, app) => {
|
|
897
|
+
try {
|
|
898
|
+
const raw = await fsp.readFile(app.settingsFile, 'utf8');
|
|
899
|
+
return { settings: JSON.parse(raw), file: app.settingsFile, stored: true };
|
|
900
|
+
} catch (err) {
|
|
901
|
+
// Nothing saved yet is the normal first-run answer, not a fault: the client then keeps its
|
|
902
|
+
// own defaults and offers to push them up. A CORRUPT file is different and says so.
|
|
903
|
+
if (err.code === 'ENOENT') return { settings: null, file: app.settingsFile, stored: false };
|
|
904
|
+
return { settings: null, file: app.settingsFile, stored: false, error: `unreadable: ${err.message}` };
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
method: 'POST', path: '/api/settings', auth: 'any', csrf: true, body: true,
|
|
910
|
+
handler: async (ctx, app) => {
|
|
911
|
+
configWriteAllowed(app, ctx);
|
|
912
|
+
const s = ctx.body?.settings;
|
|
913
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
|
914
|
+
throw new HttpError(400, 'send { settings: { ... } }', { code: 'bad_settings' });
|
|
915
|
+
}
|
|
916
|
+
// A cap, because this is a body from a browser and the file is written to disk. The whole
|
|
917
|
+
// settled object is a couple of kilobytes; 256 KB is room to grow and still far from a way
|
|
918
|
+
// to fill a disk one POST at a time.
|
|
919
|
+
const text = `${JSON.stringify(s, null, 2)}\n`;
|
|
920
|
+
if (text.length > 262_144) throw new HttpError(413, 'settings too large', { code: 'too_large' });
|
|
921
|
+
|
|
922
|
+
await fsp.mkdir(path.dirname(app.settingsFile), { recursive: true });
|
|
923
|
+
const tmp = `${app.settingsFile}.tmp`;
|
|
924
|
+
const fh = await fsp.open(tmp, 'w', 0o600);
|
|
925
|
+
await fh.writeFile(text);
|
|
926
|
+
await fh.sync();
|
|
927
|
+
await fh.close();
|
|
928
|
+
await fsp.rename(tmp, app.settingsFile);
|
|
929
|
+
|
|
930
|
+
return { ok: true, file: app.settingsFile, bytes: text.length };
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
];
|
|
934
|
+
|
|
935
|
+
// ----------------------------------------------------------------- views
|
|
936
|
+
|
|
937
|
+
function fullState(ctx, app) {
|
|
938
|
+
const m = pickNode(ctx, app);
|
|
939
|
+
// ?series=none is the cheap poll; the chart data comes from /api/series or the
|
|
940
|
+
// separate `series` SSE event on its own slower cadence.
|
|
941
|
+
const wantSeries = ctx.query.series === 'none' ? {} : {
|
|
942
|
+
hour: parseRange(ctx.query.range, 3600_000),
|
|
943
|
+
hours6: parseRange(ctx.query.range6, 21600_000),
|
|
944
|
+
day: RANGES['24h'],
|
|
945
|
+
};
|
|
946
|
+
const s = m.snapshot({ seriesRanges: wantSeries });
|
|
947
|
+
return {
|
|
948
|
+
...s,
|
|
949
|
+
app: {
|
|
950
|
+
version: app.version,
|
|
951
|
+
build: app.build,
|
|
952
|
+
scheme: app.scheme,
|
|
953
|
+
uptimeSec: Math.round((Date.now() - app.startedAt) / 1000),
|
|
954
|
+
sseClients: app.hub.stats().clients,
|
|
955
|
+
self: app.selfTelemetry(),
|
|
956
|
+
serverTime: Date.now(),
|
|
957
|
+
},
|
|
958
|
+
user: ctx.user ? publicUser(ctx.user) : null,
|
|
959
|
+
seq: app.stateSeq,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
964
|
+
const TX_PAGE = 50; // txids returned per block view; the count is always the real one
|
|
965
|
+
|
|
966
|
+
function drillError(m, query, err, hint) {
|
|
967
|
+
// 200 with ok:false, like /api/rpc: the node's own refusal is the information,
|
|
968
|
+
// and a 5xx would bury it behind the server's generic error shape.
|
|
969
|
+
return {
|
|
970
|
+
ok: false,
|
|
971
|
+
node: m.id,
|
|
972
|
+
query,
|
|
973
|
+
error: { message: err?.message ?? String(err), code: err?.code ?? null, kind: err?.kind ?? 'rpc' },
|
|
974
|
+
hint: hint ?? null,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* One block: header, statistics, and the first page of txids.
|
|
980
|
+
*
|
|
981
|
+
* Accepts ?hash=, ?height=, or neither (the current tip). A height that is not
|
|
982
|
+
* stored is the node's own error, quoted rather than turned into a 404 of our own
|
|
983
|
+
* invention -- the difference matters when the answer is "pruned", not "typo".
|
|
984
|
+
*/
|
|
985
|
+
async function blockDrill(ctx, app) {
|
|
986
|
+
const m = pickNode(ctx, app);
|
|
987
|
+
const hashArg = ctx.query.hash ? String(ctx.query.hash).trim() : null;
|
|
988
|
+
const heightArg = ctx.query.height != null && ctx.query.height !== '' ? String(ctx.query.height).trim() : null;
|
|
989
|
+
const query = { hash: hashArg, height: heightArg };
|
|
990
|
+
if (hashArg && !HEX64.test(hashArg)) throw new HttpError(400, `"${hashArg}" is not a 64-hex-character block hash`);
|
|
991
|
+
if (heightArg != null && !/^\d{1,12}$/.test(heightArg)) throw new HttpError(400, `"${heightArg}" is not a block height`);
|
|
992
|
+
// AND NOT ABSURDLY ABOVE THE TIP. The regex alone admits 999,999,999,999, and each such request
|
|
993
|
+
// spends a turn in the node's SINGLE-THREADED RPC lane only to be told "block height out of
|
|
994
|
+
// range" -- the lane this whole app is built to be careful with. (Audit, 2026-09-13.)
|
|
995
|
+
//
|
|
996
|
+
// The 1000-block margin is deliberate, not slack: our chainInfo is a cached poll and can be a
|
|
997
|
+
// block or two behind, so clamping hard at the tip would refuse the block that was mined a
|
|
998
|
+
// second ago. Rejecting a real block the operator just saw would be a worse defect than the
|
|
999
|
+
// wasted turn this prevents.
|
|
1000
|
+
const knownTip = m.state.chainInfo?.blocks ?? null;
|
|
1001
|
+
if (heightArg != null && knownTip != null && Number(heightArg) > knownTip + 1000) {
|
|
1002
|
+
throw new HttpError(400, `block ${heightArg} is above this node's tip (${knownTip})`, { code: 'above_tip' });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
let height = heightArg != null ? Number(heightArg) : null;
|
|
1006
|
+
let hash = hashArg;
|
|
1007
|
+
try {
|
|
1008
|
+
if (!hash) {
|
|
1009
|
+
if (height == null) {
|
|
1010
|
+
height = m.state.chainInfo?.blocks ?? null;
|
|
1011
|
+
if (height == null) throw new HttpError(503, 'this node has not answered getblockchaininfo yet, so there is no tip to show');
|
|
1012
|
+
}
|
|
1013
|
+
hash = await m.rpc.call('getblockhash', [height]);
|
|
1014
|
+
}
|
|
1015
|
+
const block = await m.rpc.call('getblock', [hash, 1]);
|
|
1016
|
+
// getblockstats by hash: one extra lane turn, and the only way to get per-block
|
|
1017
|
+
// fees without fetching the whole block's transactions.
|
|
1018
|
+
const stats = await m.rpc.call('getblockstats', [hash, ['totalfee', 'txs', 'size', 'weight', 'avgfee', 'medianfee', 'maxfee', 'feerate_percentiles', 'subsidy', 'utxo_increase', 'ins', 'outs']])
|
|
1019
|
+
.catch((err) => ({ error: { message: err.message } }));
|
|
1020
|
+
const txids = Array.isArray(block.tx) ? block.tx : [];
|
|
1021
|
+
return {
|
|
1022
|
+
ok: true,
|
|
1023
|
+
node: m.id,
|
|
1024
|
+
requested: { ...query, resolvedHash: hash, resolvedHeight: block.height ?? height },
|
|
1025
|
+
ms: null,
|
|
1026
|
+
header: {
|
|
1027
|
+
hash: block.hash ?? hash,
|
|
1028
|
+
confirmations: block.confirmations ?? null,
|
|
1029
|
+
height: block.height ?? height,
|
|
1030
|
+
version: block.version ?? null,
|
|
1031
|
+
size: block.size ?? null,
|
|
1032
|
+
weight: block.weight ?? null,
|
|
1033
|
+
time: block.time ?? null,
|
|
1034
|
+
mediantime: block.mediantime ?? null,
|
|
1035
|
+
merkleRoot: block.merkleroot ?? null,
|
|
1036
|
+
txCount: txids.length || block.nTx || null,
|
|
1037
|
+
nTx: block.nTx ?? txids.length,
|
|
1038
|
+
previousblockhash: block.previousblockhash ?? null,
|
|
1039
|
+
nextblockhash: block.nextblockhash ?? null,
|
|
1040
|
+
bits: block.bits ?? null,
|
|
1041
|
+
difficulty: block.difficulty ?? null,
|
|
1042
|
+
chainTrust: block.chaintrust ?? block.chainwork ?? null,
|
|
1043
|
+
},
|
|
1044
|
+
stats: stats?.error ? null : stats,
|
|
1045
|
+
statsError: stats?.error?.message ?? null,
|
|
1046
|
+
txids: txids.slice(0, TX_PAGE),
|
|
1047
|
+
txidsShown: Math.min(txids.length, TX_PAGE),
|
|
1048
|
+
txidsTotal: txids.length || block.nTx || null,
|
|
1049
|
+
truncated: txids.length > TX_PAGE,
|
|
1050
|
+
notes: [
|
|
1051
|
+
'header + txids only: getblock verbosity=2 costs this node 11 MB of hex per block (measured 2026-09-08) and still omits Core\'s fee fields',
|
|
1052
|
+
txids.length > TX_PAGE ? `${txids.length - TX_PAGE} further txid(s) not listed; ask the node directly or drill in from a mempool row` : null,
|
|
1053
|
+
block.confirmations === 0 ? 'confirmations 0: this is not on the best chain (orphan or reorged out)' : null,
|
|
1054
|
+
].filter(Boolean),
|
|
1055
|
+
};
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
if (err instanceof HttpError) throw err;
|
|
1058
|
+
const hint = /blocks of availability|have block|No such block|not on disk|Cannot obtain block/i.test(err?.message ?? '')
|
|
1059
|
+
? 'the node does not have this block stored (pruned, or a height from before the last prune); pick a height the block list actually covers'
|
|
1060
|
+
: null;
|
|
1061
|
+
return drillError(m, query, err, hint);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* One transaction, decoded by the node (verbosity 1) rather than by us.
|
|
1067
|
+
*
|
|
1068
|
+
* What is *not* here is the fee. Computing it means fetching every input's
|
|
1069
|
+
* prevout, which is N more lane turns per lookup on a server with one thread, and
|
|
1070
|
+
* this node does not include fee/deltafee in its verbose reply anyway (MEASUREMENTS
|
|
1071
|
+
* §6). So `notReported` names it, and no field pretends otherwise (rule 3).
|
|
1072
|
+
*/
|
|
1073
|
+
async function txDrill(ctx, app) {
|
|
1074
|
+
const m = pickNode(ctx, app);
|
|
1075
|
+
const txid = ctx.query.txid ? String(ctx.query.txid).trim() : null;
|
|
1076
|
+
const blockHash = ctx.query.block ? String(ctx.query.block).trim() : null;
|
|
1077
|
+
const query = { txid, block: blockHash };
|
|
1078
|
+
if (!txid) throw new HttpError(400, 'txid is required');
|
|
1079
|
+
if (!HEX64.test(txid)) throw new HttpError(400, `"${txid}" is not a 64-hex-character txid`);
|
|
1080
|
+
if (blockHash && !HEX64.test(blockHash)) throw new HttpError(400, `"${blockHash}" is not a block hash`);
|
|
1081
|
+
try {
|
|
1082
|
+
const params = blockHash ? [txid, true, blockHash] : [txid, true];
|
|
1083
|
+
const tx = await m.rpc.call('getrawtransaction', params);
|
|
1084
|
+
if (typeof tx === 'string') {
|
|
1085
|
+
// verbosity 1 asked for, hex came back: this node answered the compact form.
|
|
1086
|
+
return {
|
|
1087
|
+
ok: true, node: m.id, requested: query, decoded: false, sizeHex: tx.length / 2,
|
|
1088
|
+
notes: ['the node answered with raw hex despite verbosity=1; decoding it here would be inventing a parser for a node whose answers have already changed shape three times today'],
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
const trunc = (s, n = 96) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s ?? null);
|
|
1092
|
+
return {
|
|
1093
|
+
ok: true,
|
|
1094
|
+
node: m.id,
|
|
1095
|
+
requested: query,
|
|
1096
|
+
txid: tx.txid ?? txid,
|
|
1097
|
+
size: tx.size ?? null,
|
|
1098
|
+
vsize: tx.vsize ?? null,
|
|
1099
|
+
weight: tx.weight ?? null,
|
|
1100
|
+
version: tx.version ?? null,
|
|
1101
|
+
locktime: tx.locktime ?? null,
|
|
1102
|
+
inMempool: tx.confirmations == null,
|
|
1103
|
+
blockHash: tx.blockhash ?? null,
|
|
1104
|
+
blockHeight: tx.blockheight ?? null,
|
|
1105
|
+
confirmations: tx.confirmations ?? null,
|
|
1106
|
+
blockTime: tx.blocktime ?? null,
|
|
1107
|
+
time: tx.time ?? null,
|
|
1108
|
+
inputs: (tx.vin ?? []).slice(0, 40).map((v) => ({
|
|
1109
|
+
txid: v.txid ?? null,
|
|
1110
|
+
vout: v.vout ?? null,
|
|
1111
|
+
sequence: v.sequence ?? null,
|
|
1112
|
+
scriptSigAsm: trunc(v.scriptSig?.asm),
|
|
1113
|
+
scriptSigType: v.scriptSig?.type ?? null,
|
|
1114
|
+
witness: Array.isArray(v.txinwitness) ? v.txinwitness.map((w) => trunc(w, 32)) : null,
|
|
1115
|
+
value: v.value ?? null,
|
|
1116
|
+
address: v.address ?? null,
|
|
1117
|
+
})),
|
|
1118
|
+
inputsTotal: (tx.vin ?? []).length,
|
|
1119
|
+
outputs: (tx.vout ?? []).slice(0, 40).map((v) => ({
|
|
1120
|
+
n: v.n ?? null,
|
|
1121
|
+
value: v.value ?? null,
|
|
1122
|
+
scriptPubKeyType: v.scriptPubKey?.type ?? null,
|
|
1123
|
+
address: v.scriptPubKey?.address ?? v.scriptPubKey?.addresses?.[0] ?? null,
|
|
1124
|
+
scriptPubKeyAsm: trunc(v.scriptPubKey?.asm),
|
|
1125
|
+
spent: v.spentIndex ? { txid: v.spentIndex.spendingTxid, n: v.spentIndex.spendingIndex } : null,
|
|
1126
|
+
})),
|
|
1127
|
+
outputsTotal: (tx.vout ?? []).length,
|
|
1128
|
+
totalOutSat: (tx.vout ?? []).reduce((a, v) => (Number.isFinite(v?.value) ? a + Math.round(v.value * 1e8) : a), 0) || null,
|
|
1129
|
+
notReported: [
|
|
1130
|
+
'fee / feerate (needs every input\'s prevout, which is N more turns on a one-threaded RPC server; this node also omits fee from its verbose reply)',
|
|
1131
|
+
(tx.vout ?? []).length > 40 ? `${(tx.vout ?? []).length - 40} output(s) beyond the first 40 not listed` : null,
|
|
1132
|
+
(tx.vin ?? []).length > 40 ? `${(tx.vin ?? []).length - 40} input(s) beyond the first 40 not listed` : null,
|
|
1133
|
+
].filter(Boolean),
|
|
1134
|
+
};
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
if (err instanceof HttpError) throw err;
|
|
1137
|
+
const hint = /Transaction not found|no information available|not found in the chain|does not exist/i.test(err?.message ?? '')
|
|
1138
|
+
? 'not in the mempool' + (blockHash ? '' : ' — if it is confirmed, add &block=<blockhash>, which some nodes need to find a transaction that has left the pool')
|
|
1139
|
+
: null;
|
|
1140
|
+
return drillError(m, query, err, hint);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function mempoolView(m) {
|
|
1145
|
+
const s = m.snapshot({ seriesRanges: {} });
|
|
1146
|
+
return {
|
|
1147
|
+
node: m.id,
|
|
1148
|
+
// What kind of data this is, stated rather than implied by the word "live":
|
|
1149
|
+
// the node refuses zmqpubsequence, so there is no per-transaction add/remove
|
|
1150
|
+
// stream to show and no UI wording should imply one (docs/DEFECTS.md).
|
|
1151
|
+
feed: {
|
|
1152
|
+
kind: 'poll',
|
|
1153
|
+
cadenceSec: 20,
|
|
1154
|
+
streamAvailable: false,
|
|
1155
|
+
why: 'the node refuses zmqpubsequence: it can publish adds but has no clean "removed" choke point, so a diff of successive polls would report evictions as removes only when the poll happened to straddle them',
|
|
1156
|
+
source: 'getrawmempool verbose on the 20 s pool tier',
|
|
1157
|
+
},
|
|
1158
|
+
info: s.mempool,
|
|
1159
|
+
// The full distribution including the scatter points, which the snapshot
|
|
1160
|
+
// deliberately leaves out.
|
|
1161
|
+
dist: m.state.mempoolDist,
|
|
1162
|
+
// Fields this node does not report, stated so the UI shows "not reported"
|
|
1163
|
+
// instead of an empty box that reads as zero.
|
|
1164
|
+
notReported: [
|
|
1165
|
+
s.mempool.dist?.pendingAncestors == null ? 'pendingancestors' : null,
|
|
1166
|
+
s.mempool.dist?.replaceable == null ? 'replaceable (BIP125) flag' : null,
|
|
1167
|
+
'fees.prioritiserved', 'modifiedfees', 'ancestorcount/ancestorfees', 'withdrawreason', 'replaced-by',
|
|
1168
|
+
].filter(Boolean),
|
|
1169
|
+
log: {
|
|
1170
|
+
lastDrain: s.logState?.lastDrain ?? s.mempool.lastDrain ?? null,
|
|
1171
|
+
orphans: m.state.logState.orphans ?? null,
|
|
1172
|
+
orphanDetail: m.state.logState.orphanDetail ?? null,
|
|
1173
|
+
accept: m.state.logState.lastTxAccept ?? null,
|
|
1174
|
+
relayRate: m.state.logState.relayRate ?? null,
|
|
1175
|
+
},
|
|
1176
|
+
fees: s.fees,
|
|
1177
|
+
history: {
|
|
1178
|
+
count: app_ring(m, 'mempool', 'count'),
|
|
1179
|
+
usage: app_ring(m, 'mempool', 'usage'),
|
|
1180
|
+
},
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function app_ring(m, seriesName, field) {
|
|
1185
|
+
const r = m.history?.ring?.(seriesName);
|
|
1186
|
+
if (!r) return null;
|
|
1187
|
+
return r.tail(60).map((row) => ({ t: row.t, v: row[field] ?? null }));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function peersView(m) {
|
|
1191
|
+
const s = m.snapshot({ seriesRanges: {} });
|
|
1192
|
+
return {
|
|
1193
|
+
node: m.id,
|
|
1194
|
+
counts: {
|
|
1195
|
+
connections: s.peers.connections, in: s.peers.in, out: s.peers.out, wanted: s.peers.wanted,
|
|
1196
|
+
budget: s.peers.budget, banned: s.peers.banned, bannedOf: s.peers.bannedOf,
|
|
1197
|
+
},
|
|
1198
|
+
ranking: s.peers.ranking,
|
|
1199
|
+
identitySource: s.peers.identitySource,
|
|
1200
|
+
// The `[dl]` identity rows (user agent, protocol, the peer's own height, direction).
|
|
1201
|
+
// Parsed since the first build of this monitor, returned by the snapshot, and never
|
|
1202
|
+
// drawn until now — data collected and then dropped on the floor.
|
|
1203
|
+
identity: s.peers.identity ?? null,
|
|
1204
|
+
rpcRows: s.peers.rpcRows,
|
|
1205
|
+
rpcUpdatedAt: s.peers.rpcRowsUpdatedAt,
|
|
1206
|
+
// The peer table the node's own RPC gives us, verbatim, next to the log
|
|
1207
|
+
// derived activity that exists because it is empty.
|
|
1208
|
+
rpcPeers: m.state.peers.list,
|
|
1209
|
+
activity: s.peers.activity,
|
|
1210
|
+
recentEvents: s.peers.recentEvents,
|
|
1211
|
+
network: s.network,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Exported for tests: this is the function that decides whether an absent figure is
|
|
1216
|
+
// reported as absent, so it must be reachable without booting an app and a fake node.
|
|
1217
|
+
export function netView(m) {
|
|
1218
|
+
const s = m.snapshot({ seriesRanges: {} });
|
|
1219
|
+
// Every gap in one list, in words. It used to hold exactly one entry (upload), which
|
|
1220
|
+
// meant that in RPC-only mode — where a dozen figures lose their only source at once
|
|
1221
|
+
// — the list came back *empty*, saying less the less there was. That is the inverse of
|
|
1222
|
+
// the purpose: the list has to grow when the sources go away.
|
|
1223
|
+
const unavailable = [];
|
|
1224
|
+
if (!s.net.uploadMeasured) {
|
|
1225
|
+
unavailable.push('outbound bytes / upload rate: getnettotals reports 0 in this deployment and no other source carries it');
|
|
1226
|
+
}
|
|
1227
|
+
if (!s.net.downloadMeasured) {
|
|
1228
|
+
unavailable.push('inbound bytes / download rate: totalbytesrecv has read 0 for the whole uptime on this build, so 0 B/s would be a claim about an idle node rather than an absence');
|
|
1229
|
+
}
|
|
1230
|
+
if (!m.logEnabled) {
|
|
1231
|
+
unavailable.push('log-only figures (no RPC source, and the log tail is off by configuration): which peer served a block, per-peer download rate and relay legs, the mempool accept/reject breakdown, disk-write rate and write totals, the download worker\'s banned-peer count, the node\'s own IBD eta, UTXO compaction and validation stalls, archive-layout holes, sync_failing');
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
node: m.id,
|
|
1235
|
+
measured: {
|
|
1236
|
+
inBps: s.net.inBps, diskWriteBps: s.net.diskWriteBps,
|
|
1237
|
+
netTotalLog: s.net.netTotalLog, diskTotal: s.net.diskTotal,
|
|
1238
|
+
avgRecv: s.net.avgRecv, avgWrite: s.net.avgWrite,
|
|
1239
|
+
floor: s.net.floor, poolMedian: s.net.poolMedian,
|
|
1240
|
+
// Named per mode. Claiming "node log [dlc] tick lines" while the tail is closed
|
|
1241
|
+
// is a provenance lie. Note what this string deliberately does NOT say: it used
|
|
1242
|
+
// to assert "the deployed build counts 0 bytes", which was false within the hour
|
|
1243
|
+
// of being written (MEASUREMENTS 23 — the same process read 0/0 at 09:36 and
|
|
1244
|
+
// 23,955,131 bytes at 17:36, no restart). A row that names a build's behaviour is
|
|
1245
|
+
// a claim that rots; the gate on the field next to it is the live answer.
|
|
1246
|
+
source: m.logEnabled
|
|
1247
|
+
? 'node log [dlc] tick lines'
|
|
1248
|
+
: 'getnettotals delta rate (RPC) — reported only while that counter moves; it has read 0 for whole uptimes on some builds and started counting mid-uptime on others',
|
|
1249
|
+
downloadMeasured: s.net.downloadMeasured,
|
|
1250
|
+
},
|
|
1251
|
+
rpc: { totalRecv: s.net.totalRecvRpc, totalSent: s.net.totalSentRpc, uploadtarget: s.net.uploadtarget, uploadMeasured: s.net.uploadMeasured },
|
|
1252
|
+
// An upload rate we do not have is said so here, in words, so no chart can
|
|
1253
|
+
// imply one. See the nettotals-zero quality flag.
|
|
1254
|
+
unavailable,
|
|
1255
|
+
peers: { connections: s.peers.connections, in: s.peers.in, out: s.peers.out, wanted: s.peers.wanted },
|
|
1256
|
+
series: s.series.net,
|
|
1257
|
+
formatted: { inBps: s.net.inBps == null ? null : formatBytes(s.net.inBps) + '/s', disk: s.net.diskTotal == null ? null : formatBytes(s.net.diskTotal) },
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function blockStats(recent) {
|
|
1262
|
+
if (!recent.length) return null;
|
|
1263
|
+
const fees = recent.map((b) => b.totalfee).filter((v) => v != null);
|
|
1264
|
+
const sizes = recent.map((b) => b.size).filter((v) => v != null);
|
|
1265
|
+
const txs = recent.map((b) => b.txs).filter((v) => v != null);
|
|
1266
|
+
const gaps = recent.map((b) => b.gapSec).filter((v) => v != null && v >= 0 && v < 7200);
|
|
1267
|
+
const sum = (a) => a.reduce((x, y) => x + y, 0);
|
|
1268
|
+
return {
|
|
1269
|
+
count: recent.length,
|
|
1270
|
+
spanSec: recent.length > 1 ? Math.round((recent[0].t - recent[recent.length - 1].t) / 1000) : null,
|
|
1271
|
+
totalFeesSat: fees.length ? sum(fees) : null,
|
|
1272
|
+
avgFeesSat: fees.length ? Math.round(sum(fees) / fees.length) : null,
|
|
1273
|
+
avgSize: sizes.length ? Math.round(sum(sizes) / sizes.length) : null,
|
|
1274
|
+
maxSize: sizes.length ? Math.max(...sizes) : null,
|
|
1275
|
+
avgTxs: txs.length ? Math.round(sum(txs) / txs.length) : null,
|
|
1276
|
+
avgGapSec: gaps.length ? +(sum(gaps) / gaps.length).toFixed(1) : null,
|
|
1277
|
+
medGapSec: gaps.length ? gaps.slice().sort((a, b) => a - b)[Math.floor(gaps.length / 2)] : null,
|
|
1278
|
+
etaCadence: gaps.length ? formatEta(Math.round(sum(gaps) / gaps.length)) : null,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function visibleActions(cfg, role) {
|
|
1283
|
+
return Object.entries(ACTIONS).map(([name, def]) => ({
|
|
1284
|
+
name, label: def.label, note: def.note, args: def.args, requiredRole: def.role,
|
|
1285
|
+
enabled: cfg.actions.enabled && cfg.actions.allow.includes(name),
|
|
1286
|
+
permittedForYou: actionAllowed(cfg, name, role).ok,
|
|
1287
|
+
method: def.method,
|
|
1288
|
+
}));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function publicUser(u) {
|
|
1292
|
+
if (!u) return null;
|
|
1293
|
+
return { username: u.username, role: u.role, id: u.id, disabled: !!u.disabled, lastLoginAt: u.lastLoginAt ?? null };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function normaliseArgs(spec = [], args = {}) {
|
|
1297
|
+
const out = [];
|
|
1298
|
+
for (const s of spec) {
|
|
1299
|
+
const key = s.replace(/\?$/, '');
|
|
1300
|
+
const optional = s.endsWith('?');
|
|
1301
|
+
if (args?.[key] !== undefined) out.push(args[key]);
|
|
1302
|
+
else if (!optional) out.push(undefined);
|
|
1303
|
+
}
|
|
1304
|
+
while (out.length && out[out.length - 1] === undefined) out.pop();
|
|
1305
|
+
return out;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function preview(result) {
|
|
1309
|
+
const s = typeof result === 'string' ? result : JSON.stringify(result);
|
|
1310
|
+
return s == null ? null : s.slice(0, 200);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function clampInt(v, min, max, dflt) {
|
|
1314
|
+
const n = Number(v);
|
|
1315
|
+
if (!Number.isFinite(n)) return dflt;
|
|
1316
|
+
return Math.max(min, Math.min(max, Math.round(n)));
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
export { ban };
|