blockyard 0.1.0 → 0.1.2
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 +112 -0
- package/README.md +13 -11
- package/SECURITY.md +2 -2
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/CONFIGURATION.md +6 -4
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +14 -7
- package/docs/INSTALL.md +7 -4
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +26 -7
- package/docs/TROUBLESHOOTING.md +10 -5
- package/docs/USER-GUIDE.md +247 -9
- package/package.json +4 -2
- package/public/css/app.css +87 -0
- package/public/index.html +58 -6
- package/public/js/app.js +60 -19
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +51 -11
- package/public/js/depthchart.js +1 -1
- package/public/js/details3d.js +25 -2
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +3 -2
- package/public/js/mining.js +4 -4
- package/public/js/panels.js +27 -18
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1071 -0
- package/public/js/scorchedai.js +268 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1361 -0
- package/public/js/settings.js +266 -80
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/scripts/check.js +46 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +108 -10
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +6 -5
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +432 -56
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +24 -11
- package/server/collect/network.js +19 -9
- package/server/config.js +7 -0
- package/server/http/api.js +70 -13
- package/server/http/server.js +22 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +13 -3
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/systemd/blockyard.service +34 -3
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
// empty in this deployment -- so `health.quality` records every such gap
|
|
16
16
|
// rather than silently preferring one.
|
|
17
17
|
import { EventEmitter } from 'node:events';
|
|
18
|
-
import { RpcClient, RpcError } from '../rpc/client.js';
|
|
18
|
+
import { RpcClient, RpcError, displayUrl, shortPath } from '../rpc/client.js';
|
|
19
19
|
import { LogTail } from './logtail.js';
|
|
20
20
|
import { CounterRate } from '../store/ring.js';
|
|
21
21
|
import { computeSync, stripFacts } from './sync.js';
|
|
22
22
|
import { SHAPES, RULE_TO_SHAPE } from './logparse.js';
|
|
23
|
-
import {
|
|
23
|
+
import { decodeCoinbaseSafe, minerRow, ledgerApply, ledgerRows, aliasFor, matchPool } from './mining.js';
|
|
24
24
|
import { NetworkStats } from './network.js';
|
|
25
25
|
import { summarizeTemplate, packagesFromTemplate, blockEconomy, templateCells } from './nextblock.js';
|
|
26
26
|
import { templateFromMempool, LOCAL_TEMPLATE_NOTE } from './gbt.js';
|
|
@@ -39,6 +39,15 @@ export const BLOCKSTATS_FIELDS = ['totalfee', 'txs', 'total_size', 'total_weight
|
|
|
39
39
|
'swtotal_size', 'swtxs', 'subsidy', 'utxo_increase', 'ins', 'outs', 'avgfee', 'medianfee', 'maxfee',
|
|
40
40
|
'feerate_percentiles', 'height', 'blockhash', 'time', 'mediantime'];
|
|
41
41
|
|
|
42
|
+
|
|
43
|
+
// WHAT THE NODE SAYS IS DATA OF A KNOWN SHAPE, OR NOTHING (audit 2026-09-16, L2). These fields went
|
|
44
|
+
// from the RPC reply to the page unchecked, and the page wrote some of them as markup: a node that
|
|
45
|
+
// answered `chain: "main<img src=x onerror=…>"` put an element on the Chain page. The page now
|
|
46
|
+
// escapes them too; the server also refuses to pass on a value that is not the type it names.
|
|
47
|
+
export const chainName = (v) => (typeof v === 'string' && /^[a-z0-9_-]{1,24}$/i.test(v) ? v : null);
|
|
48
|
+
export const boolOrNull = (v) => (typeof v === 'boolean' ? v : null);
|
|
49
|
+
export const countOrNull = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
|
|
50
|
+
|
|
42
51
|
export class NodeMonitor extends EventEmitter {
|
|
43
52
|
constructor(nodeCfg, { rpc, poll, store, log, history, logCfg, miningCfg }) {
|
|
44
53
|
super();
|
|
@@ -888,7 +897,11 @@ export class NodeMonitor extends EventEmitter {
|
|
|
888
897
|
const rtRes = await this.rpc.batch([{ method: 'getrawtransaction', params: [cbTxid, 2] }], { priority: 3 });
|
|
889
898
|
if (!rtRes?.[0]?.ok) throw new Error(rtRes?.[0]?.error?.message ?? 'getrawtransaction unanswered');
|
|
890
899
|
const vin0 = rtRes[0].result?.vin?.[0] ?? {};
|
|
891
|
-
|
|
900
|
+
// 2026-09-16 (audit L4): everything above that throws is the node not answering, and
|
|
901
|
+
// pumpMining puts the height back and backs off. What follows is the block's own bytes,
|
|
902
|
+
// and a coinbase we cannot read will read the same way next time -- so it never throws:
|
|
903
|
+
// the block is recorded as unparseable (an unknown pool) and the queue moves on.
|
|
904
|
+
const decoded = decodeCoinbaseSafe(vin0.coinbase ?? vin0.coinbaseHex ?? '');
|
|
892
905
|
const stats = this.state.blocks.get(height) ?? {};
|
|
893
906
|
const row = minerRow({
|
|
894
907
|
height, hash,
|
|
@@ -1821,8 +1834,8 @@ export class NodeMonitor extends EventEmitter {
|
|
|
1821
1834
|
label: s.label,
|
|
1822
1835
|
color: s.color,
|
|
1823
1836
|
online: this.rpc.telemetry().online,
|
|
1824
|
-
chain: s.chain,
|
|
1825
|
-
ibd: s.chainInfo?.initialblockdownload
|
|
1837
|
+
chain: chainName(s.chain),
|
|
1838
|
+
ibd: boolOrNull(s.chainInfo?.initialblockdownload),
|
|
1826
1839
|
tip: {
|
|
1827
1840
|
height: s.chainInfo?.blocks ?? null,
|
|
1828
1841
|
headers: s.chainInfo?.headers ?? null,
|
|
@@ -1841,12 +1854,12 @@ export class NodeMonitor extends EventEmitter {
|
|
|
1841
1854
|
sync: computeSync({
|
|
1842
1855
|
blocks: s.chainInfo?.blocks ?? null,
|
|
1843
1856
|
headers: s.chainInfo?.headers ?? null,
|
|
1844
|
-
ibd: s.chainInfo?.initialblockdownload
|
|
1857
|
+
ibd: boolOrNull(s.chainInfo?.initialblockdownload),
|
|
1845
1858
|
verificationProgress: s.chainInfo?.verificationprogress ?? null,
|
|
1846
1859
|
tipTime: s.chainInfo?.time ?? null,
|
|
1847
1860
|
bestHash: s.chainInfo?.bestblockhash ?? null,
|
|
1848
1861
|
sizeOnDisk: s.chainInfo?.size_on_disk ?? null,
|
|
1849
|
-
chain: s.chain,
|
|
1862
|
+
chain: chainName(s.chain),
|
|
1850
1863
|
warnings: s.chainInfo?.warnings ?? [],
|
|
1851
1864
|
blockRatePerSec: this.blockRateFast.rate(),
|
|
1852
1865
|
blockRateFastSpanMs: this.blockRateFast.span,
|
|
@@ -1875,7 +1888,7 @@ export class NodeMonitor extends EventEmitter {
|
|
|
1875
1888
|
hashrateNote: hashrateSuppressed ?? (hashrateRaw == null ? 'difficulty or a block-gap sample is missing' : null),
|
|
1876
1889
|
avgBlockGapSec: avgGap,
|
|
1877
1890
|
sizeOnDisk: s.chainInfo?.size_on_disk ?? null,
|
|
1878
|
-
pruned: s.chainInfo?.pruned
|
|
1891
|
+
pruned: boolOrNull(s.chainInfo?.pruned),
|
|
1879
1892
|
chainwork: s.chainInfo?.chainwork ?? null,
|
|
1880
1893
|
uptimeSec: s.uptimeSec ?? null,
|
|
1881
1894
|
network: s.networkInfo ? {
|
|
@@ -1901,7 +1914,7 @@ export class NodeMonitor extends EventEmitter {
|
|
|
1901
1914
|
minFee: mi.mempoolminfee ?? null,
|
|
1902
1915
|
minRelayFee: mi.minrelaytxfee ?? null,
|
|
1903
1916
|
incrementalRelayFee: mi.incrementalrelayfee ?? null,
|
|
1904
|
-
unbroadcast: mi.unbroadcastcount
|
|
1917
|
+
unbroadcast: countOrNull(mi.unbroadcastcount),
|
|
1905
1918
|
maxDataCarrier: mi.maxdatacarriersize ?? null,
|
|
1906
1919
|
permitBareMultisig: mi.permitbaremultisig ?? null,
|
|
1907
1920
|
ingestRate: s.logState.relayRate ?? s.logState.acceptRate ?? null,
|
|
@@ -2038,7 +2051,7 @@ export class NodeMonitor extends EventEmitter {
|
|
|
2038
2051
|
indexes: s.indexes,
|
|
2039
2052
|
tips: s.tips,
|
|
2040
2053
|
deployments: s.deployments,
|
|
2041
|
-
rpcInfo: s.rpcInfo,
|
|
2054
|
+
rpcInfo: s.rpcInfo ? { ...s.rpcInfo, logpath: shortPath(s.rpcInfo.logpath) } : null, // no directories to viewers (audit 2026-09-16, L11)
|
|
2042
2055
|
log: {
|
|
2043
2056
|
...(this.tail ? this.tail.status() : { exists: false, file: null }),
|
|
2044
2057
|
// 'disabled' is a configuration decision; 'missing' would be a fault. The
|
|
@@ -2128,7 +2141,7 @@ export class NodeMonitor extends EventEmitter {
|
|
|
2128
2141
|
// the bar without drawing which node it belongs to.
|
|
2129
2142
|
out.sync.node = s.id;
|
|
2130
2143
|
out.sync.nodeLabel = s.label;
|
|
2131
|
-
out.sync.endpoint = this.cfg.rpcUrl;
|
|
2144
|
+
out.sync.endpoint = displayUrl(this.cfg.rpcUrl); // no userinfo to viewers (audit 2026-09-16, L3)
|
|
2132
2145
|
// One dense row for every state; `strip` is the ordered facts to draw in it.
|
|
2133
2146
|
out.sync.strip = stripFacts(out.sync);
|
|
2134
2147
|
return out;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// and the view says how far it has got
|
|
17
17
|
//
|
|
18
18
|
// The arithmetic is in pure functions above the class, which is what the tests hold.
|
|
19
|
-
import {
|
|
19
|
+
import { decodeCoinbaseSafe, matchPool, aliasFor, tagFingerprint } from './mining.js';
|
|
20
20
|
|
|
21
21
|
export const EPOCH = 2016;
|
|
22
22
|
export const TARGET_SPACING = 600;
|
|
@@ -256,16 +256,26 @@ export class NetworkStats {
|
|
|
256
256
|
const withCb = withHash.map((x, k) => ({ ...x, block: blocks[k]?.ok ? blocks[k].result : null })).filter((x) => x.block?.tx?.length);
|
|
257
257
|
const cbs = await this.rpc.batch(withCb.map((x) => ({ method: 'getrawtransaction', params: [x.block.tx[0], 2, x.hash] })), { priority: 7, heavy: true });
|
|
258
258
|
const map = this.poolMapOf(), aliases = this.aliasesOf();
|
|
259
|
+
// 2026-09-16 (audit L4): one block's coinbase used to be able to throw out of this loop,
|
|
260
|
+
// and pumpPools then put the whole chunk of eight back at the front and retried it every
|
|
261
|
+
// 15 s -- forever, since the bytes never change. Only the RPC calls above are worth a
|
|
262
|
+
// retry. A block whose coinbase cannot be read is a permanent fact about that block: it
|
|
263
|
+
// is recorded as an unknown pool (`unparseable`) and the week keeps filling.
|
|
259
264
|
withCb.forEach((x, k) => {
|
|
260
265
|
const tx = cbs[k]?.ok ? cbs[k].result : null;
|
|
261
|
-
const hex = tx?.vin?.[0]?.coinbase
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
const hex = typeof tx?.vin?.[0]?.coinbase === 'string' ? tx.vin[0].coinbase : '';
|
|
267
|
+
try {
|
|
268
|
+
const decoded = decodeCoinbaseSafe(hex);
|
|
269
|
+
const tagText = decoded?.tagText ?? '';
|
|
270
|
+
const matched = matchPool(map, { tagText, rawHex: hex });
|
|
271
|
+
const rawKey = decoded?.tag ? decoded.tag : tagFingerprint(tagText); // the ledger's own key rule (minerRow)
|
|
272
|
+
const key = matched ? matched.key : rawKey;
|
|
273
|
+
const name = matched ? matched.name : (aliasFor(aliases, rawKey) ?? rawKey); // the ledger shows an unlabelled pool by its key
|
|
274
|
+
this.pools.set(x.h, { height: x.h, time: x.block.time, poolKey: key, name, labelled: !!matched, tagText, ...(decoded?.decodeError ? { unparseable: true } : {}) });
|
|
275
|
+
} catch {
|
|
276
|
+
const key = tagFingerprint('');
|
|
277
|
+
this.pools.set(x.h, { height: x.h, time: x.block.time, poolKey: key, name: key, labelled: false, tagText: '', unparseable: true });
|
|
278
|
+
}
|
|
269
279
|
});
|
|
270
280
|
this.at = this.now();
|
|
271
281
|
}
|
package/server/config.js
CHANGED
|
@@ -234,6 +234,12 @@ const DEFAULTS = {
|
|
|
234
234
|
lockoutMs: 600000,
|
|
235
235
|
cookieName: 'blockyard_sid',
|
|
236
236
|
secureCookie: false, // forced true at boot when TLS is on
|
|
237
|
+
// OPEN MODE'S NODE CONNECTION IS SET FROM THIS MACHINE ONLY (audit 2026-09-16, M1/M2). With
|
|
238
|
+
// accounts off, the node-connection save and test are answered only for a loopback caller: the
|
|
239
|
+
// open-mode cross-site check stops a browser, never a script, and a script on the LAN could point
|
|
240
|
+
// the saved connection -- and with it the datadir cookie, after the next restart -- at itself, or
|
|
241
|
+
// make this server fetch its internal URLs. `true` restores the old reach, knowingly.
|
|
242
|
+
openNodeConfigFromNetwork: false,
|
|
237
243
|
},
|
|
238
244
|
actions: {
|
|
239
245
|
// Anything that can change node or machine state is off unless explicitly
|
|
@@ -402,6 +408,7 @@ export function loadConfig({ configFile = defaultConfigFile(), ifaces = null, no
|
|
|
402
408
|
'BLOCKYARD_DATA': ['store.dir', String],
|
|
403
409
|
'BLOCKYARD_AUTH': ['auth.enabled', Boolean],
|
|
404
410
|
'BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH': ['actions.allowWritesWithoutAuth', Boolean],
|
|
411
|
+
'BLOCKYARD_OPEN_NODE_CONFIG_FROM_NETWORK': ['auth.openNodeConfigFromNetwork', Boolean],
|
|
405
412
|
// Run on RPC alone: 0 turns the log tail off for every node. Measured why is
|
|
406
413
|
// in server/collect/monitor.js and MEASUREMENTS 3/4 -- bandwidth and per-peer
|
|
407
414
|
// bytes work on builds that publish them and do not on the deployed one.
|
package/server/http/api.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import fsp from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { classifyMethod, allowlistSummary, ACTIONS, actionAllowed, NODE_REFUSES } from '../rpc/allowlist.js';
|
|
7
|
-
import { RpcClient } from '../rpc/client.js';
|
|
7
|
+
import { RpcClient, displayUrl, shortPath } from '../rpc/client.js';
|
|
8
8
|
import { SERIES } from '../store/history.js';
|
|
9
9
|
import { randomPassword } from '../auth/users.js';
|
|
10
10
|
import { formatEta, formatBytes } from '../util/fmt.js';
|
|
@@ -98,6 +98,33 @@ function configWriteAllowed(app, ctx) {
|
|
|
98
98
|
if (app.cfg.auth.enabled) needRole(ctx, 'admin');
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// THE NODE CONNECTION IN OPEN MODE: FROM THIS MACHINE ONLY (audit 2026-09-16, M1 and M2).
|
|
102
|
+
//
|
|
103
|
+
// The open-mode cross-site check refuses a browser on another origin, because a browser cannot
|
|
104
|
+
// suppress Origin or forge Sec-Fetch-Site. A script sends neither header, and can forge both. So with
|
|
105
|
+
// accounts off, a script anywhere on the LAN could (1) save an rpcUrl of its choosing, after which the
|
|
106
|
+
// next restart sends the datadir's cookie to it -- reproduced end to end -- and (2) use the probe to
|
|
107
|
+
// make this server POST to internal URLs and read back what they answered. There is no identity to
|
|
108
|
+
// check in open mode, so the check is WHERE the caller is: the socket's own peer address must be
|
|
109
|
+
// loopback. X-Forwarded-For is never consulted, and behind a trusted proxy every request would look
|
|
110
|
+
// local, so the form is refused there. `auth.openNodeConfigFromNetwork` restores the old reach.
|
|
111
|
+
// Full server paths go to an admin; everyone else, which in open mode is anyone who can reach the
|
|
112
|
+
// port, gets the last two parts (audit 2026-09-16, L11).
|
|
113
|
+
const pathFor = (ctx, p) => (ctx.user?.role === 'admin' ? p : shortPath(p));
|
|
114
|
+
|
|
115
|
+
export function isLoopbackAddress(addr) {
|
|
116
|
+
const a = String(addr ?? '').replace(/^::ffff:/i, '');
|
|
117
|
+
return a === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a);
|
|
118
|
+
}
|
|
119
|
+
function nodeConfigAllowed(app, ctx) {
|
|
120
|
+
if (app.cfg.auth.enabled) return needRole(ctx, 'admin');
|
|
121
|
+
if (app.cfg.auth.openNodeConfigFromNetwork === true) return undefined;
|
|
122
|
+
if (!app.cfg.server?.trustProxy && isLoopbackAddress(ctx.req.socket?.remoteAddress)) return undefined;
|
|
123
|
+
throw new HttpError(403, app.cfg.server?.trustProxy
|
|
124
|
+
? 'with accounts off and server.trustProxy on, the node connection cannot be changed over HTTP: every proxied request looks local. Edit config/local.json, or turn accounts on'
|
|
125
|
+
: 'with accounts off, the node connection can only be changed from this machine (open the monitor at 127.0.0.1), or turn accounts on, or set auth.openNodeConfigFromNetwork', { code: 'local_only' });
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
// What a form may set, and nothing else. Credentials come from the datadir's .cookie
|
|
102
129
|
// (config.js resolveCookie), so rpcUser / rpcPassword / cookieFile are NOT accepted here: taking a
|
|
103
130
|
// password over an endpoint that is open by default is not a thing to add quietly.
|
|
@@ -538,7 +565,7 @@ export const routes = [
|
|
|
538
565
|
let sync = null;
|
|
539
566
|
try { sync = m.snapshot({ seriesRanges: {} }).sync; } catch { /* not yet populated */ }
|
|
540
567
|
return {
|
|
541
|
-
id: m.id, label: m.label, color: m.color, rpcUrl: m.node?.rpcUrl ?? m.rpc.url,
|
|
568
|
+
id: m.id, label: m.label, color: m.color, rpcUrl: displayUrl(m.node?.rpcUrl ?? m.rpc.url),
|
|
542
569
|
chain: m.state.chain, online: m.rpc.telemetry().online,
|
|
543
570
|
optional: !!m.cfg.optional,
|
|
544
571
|
syncState: sync?.state ?? null, pct: sync?.pct ?? null,
|
|
@@ -606,8 +633,11 @@ export const routes = [
|
|
|
606
633
|
const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
|
|
607
634
|
const cls = classifyMethod(method);
|
|
608
635
|
if (!cls.allowed) {
|
|
609
|
-
|
|
610
|
-
|
|
636
|
+
// No RPC name is longer than a few dozen characters; the caller's string is clamped before
|
|
637
|
+
// it is written or echoed (audit 2026-09-16, M6).
|
|
638
|
+
const shown = method.length > 64 ? `${method.slice(0, 64)}… (${method.length} chars)` : method;
|
|
639
|
+
await app.audit({ type: 'rpc-denied', username: ctx.user.username, node: m.id, method: shown, reason: cls.reason, ip: ctx.ip });
|
|
640
|
+
throw new HttpError(403, `${shown || '(empty)'} is not callable from the web UI: ${cls.reason}`, { code: 'rpc_denied' });
|
|
611
641
|
}
|
|
612
642
|
const t0 = Date.now();
|
|
613
643
|
try {
|
|
@@ -772,7 +802,7 @@ export const routes = [
|
|
|
772
802
|
{
|
|
773
803
|
method: 'POST', path: '/api/config/node/test', auth: 'any', csrf: true, body: true,
|
|
774
804
|
handler: async (ctx, app) => {
|
|
775
|
-
|
|
805
|
+
nodeConfigAllowed(app, ctx);
|
|
776
806
|
const node = candidateNode(app, ctx.body);
|
|
777
807
|
const started = Date.now();
|
|
778
808
|
// A THROWAWAY CLIENT WITH ITS OWN LANE. The live node's client holds a serialized queue
|
|
@@ -825,9 +855,22 @@ export const routes = [
|
|
|
825
855
|
// `authenticated` is reported on EVERY path, success or failure: a caller cannot otherwise
|
|
826
856
|
// tell "it refused us" from "we deliberately sent no credential", and those mean different
|
|
827
857
|
// things to someone deciding whether the connection they typed is right.
|
|
858
|
+
//
|
|
859
|
+
// NOT WHAT A FOREIGN ENDPOINT SAID (audit 2026-09-16, M2). RpcClient's message carries up to
|
|
860
|
+
// 200 characters of the reply body, so echoing it made this route a way to read internal URLs
|
|
861
|
+
// through the server. For any endpoint but the configured one, the answer is the class of
|
|
862
|
+
// failure only.
|
|
863
|
+
const GENERIC = {
|
|
864
|
+
timeout: 'the endpoint did not answer in time',
|
|
865
|
+
transport: 'the endpoint could not be reached, or answered with an HTTP error',
|
|
866
|
+
parse: 'the endpoint answered, but not with JSON-RPC: this is not a Bitcoin Core RPC port',
|
|
867
|
+
rpc: 'the endpoint answered with an RPC error',
|
|
868
|
+
breaker: 'the endpoint could not be reached',
|
|
869
|
+
};
|
|
870
|
+
const message = sameEndpoint ? err.message : (GENERIC[err.kind] ?? 'the connection failed');
|
|
828
871
|
return {
|
|
829
872
|
ok: false, ms: Date.now() - started, authenticated: sameEndpoint,
|
|
830
|
-
error: { message
|
|
873
|
+
error: { message, kind: err.kind ?? null, code: sameEndpoint ? (err.code ?? null) : null },
|
|
831
874
|
};
|
|
832
875
|
}
|
|
833
876
|
},
|
|
@@ -835,7 +878,7 @@ export const routes = [
|
|
|
835
878
|
{
|
|
836
879
|
method: 'POST', path: '/api/config/node', auth: 'any', csrf: true, body: true,
|
|
837
880
|
handler: async (ctx, app) => {
|
|
838
|
-
|
|
881
|
+
nodeConfigAllowed(app, ctx);
|
|
839
882
|
if (!app.configFile) {
|
|
840
883
|
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
884
|
}
|
|
@@ -850,7 +893,21 @@ export const routes = [
|
|
|
850
893
|
// JSON.stringify then omits it -- so carrying `{...cur}` across could DELETE a field from the
|
|
851
894
|
// file rather than preserve it. Only real values take part in the merge.
|
|
852
895
|
const changes = Object.fromEntries(Object.entries(node).filter(([, v]) => v !== undefined));
|
|
853
|
-
|
|
896
|
+
// CREDENTIALS BELONG TO AN ENDPOINT (audit 2026-09-16, M1). The merge kept rpcUser, rpcPassword
|
|
897
|
+
// and cookieFile while the URL changed, so a saved address inherited another server's secret.
|
|
898
|
+
// When the host or port changes they are dropped; the cookie is then read from the datadir the
|
|
899
|
+
// form names, which is the credential this form was always meant to use.
|
|
900
|
+
const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
|
|
901
|
+
const prior = nodes[0] ?? {};
|
|
902
|
+
const droppedCredentials = [];
|
|
903
|
+
if (prior.rpcUrl && hostOf(prior.rpcUrl) !== hostOf(changes.rpcUrl)) {
|
|
904
|
+
for (const k of ['rpcUser', 'rpcPassword', 'cookieFile']) if (k in prior) droppedCredentials.push(k);
|
|
905
|
+
}
|
|
906
|
+
nodes[0] = { ...prior, ...changes };
|
|
907
|
+
for (const k of droppedCredentials) delete nodes[0][k];
|
|
908
|
+
if (droppedCredentials.length && !nodes[0].datadir) {
|
|
909
|
+
throw new HttpError(400, 'a new endpoint needs a datadir, so its own .cookie can be read', { code: 'need_datadir' });
|
|
910
|
+
}
|
|
854
911
|
delete nodes[0].__urlOverridden;
|
|
855
912
|
const next = { ...fileCfg, nodes };
|
|
856
913
|
|
|
@@ -870,7 +927,7 @@ export const routes = [
|
|
|
870
927
|
.filter((k) => process.env[k] !== undefined && process.env[k] !== '');
|
|
871
928
|
await app.audit({ type: 'config-node', username: ctx.user.username, ip: ctx.ip, rpcUrl: node.rpcUrl, file: app.configFile });
|
|
872
929
|
return {
|
|
873
|
-
ok: true, file: app.configFile, restartRequired: true, envOverrides,
|
|
930
|
+
ok: true, file: app.configFile, restartRequired: true, envOverrides, droppedCredentials,
|
|
874
931
|
note: envOverrides.length
|
|
875
932
|
? `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
933
|
: 'saved; restart the monitor for it to take effect',
|
|
@@ -896,12 +953,12 @@ export const routes = [
|
|
|
896
953
|
handler: async (ctx, app) => {
|
|
897
954
|
try {
|
|
898
955
|
const raw = await fsp.readFile(app.settingsFile, 'utf8');
|
|
899
|
-
return { settings: JSON.parse(raw), file: app.settingsFile, stored: true };
|
|
956
|
+
return { settings: JSON.parse(raw), file: pathFor(ctx, app.settingsFile), stored: true };
|
|
900
957
|
} catch (err) {
|
|
901
958
|
// Nothing saved yet is the normal first-run answer, not a fault: the client then keeps its
|
|
902
959
|
// 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}` };
|
|
960
|
+
if (err.code === 'ENOENT') return { settings: null, file: pathFor(ctx, app.settingsFile), stored: false };
|
|
961
|
+
return { settings: null, file: pathFor(ctx, app.settingsFile), stored: false, error: `unreadable: ${err.message.replaceAll(app.settingsFile, pathFor(ctx, app.settingsFile))}` };
|
|
905
962
|
}
|
|
906
963
|
},
|
|
907
964
|
},
|
|
@@ -927,7 +984,7 @@ export const routes = [
|
|
|
927
984
|
await fh.close();
|
|
928
985
|
await fsp.rename(tmp, app.settingsFile);
|
|
929
986
|
|
|
930
|
-
return { ok: true, file: app.settingsFile, bytes: text.length };
|
|
987
|
+
return { ok: true, file: pathFor(ctx, app.settingsFile), bytes: text.length };
|
|
931
988
|
},
|
|
932
989
|
},
|
|
933
990
|
];
|
package/server/http/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import { ipDecision } from '../netinfo.js';
|
|
|
17
17
|
export { ipAllowed, parseIp, parseCidr } from '../netinfo.js';
|
|
18
18
|
|
|
19
19
|
const MAX_BODY = 1024 * 1024; // 1 MB: a raw transaction is nowhere near this
|
|
20
|
+
export const REQUEST_TIMEOUT_MS = 30_000; // the whole request, body included, must arrive within this
|
|
20
21
|
|
|
21
22
|
function compile(routesTable) {
|
|
22
23
|
return routesTable.map((r) => {
|
|
@@ -57,10 +58,13 @@ export function createAppServer(app) {
|
|
|
57
58
|
? https.createServer({ ...app.tlsOptions }, listener)
|
|
58
59
|
: http.createServer(listener);
|
|
59
60
|
server.keepAliveTimeout = 5000;
|
|
60
|
-
|
|
61
|
-
// SSE connections are
|
|
62
|
-
|
|
61
|
+
// A DEADLINE FOR RECEIVING THE WHOLE REQUEST, body included (audit 2026-09-16, M5). This was 0,
|
|
62
|
+
// "so SSE connections are not reaped" -- but requestTimeout bounds only how long the REQUEST takes
|
|
63
|
+
// to arrive, and a stream's GET has arrived the moment its headers do; the response may then run
|
|
64
|
+
// for days. Measured on Node 22: with a 1.5 s deadline a stream kept delivering for 5 s, while a
|
|
65
|
+
// POST trickling one body byte every 500 ms got 408 at 2 s. At 0, that POST was held forever.
|
|
63
66
|
server.headersTimeout = 15000;
|
|
67
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
64
68
|
|
|
65
69
|
async function handle(req, res) {
|
|
66
70
|
const ip = clientIp(req, app.cfg);
|
|
@@ -116,13 +120,20 @@ export function createAppServer(app) {
|
|
|
116
120
|
},
|
|
117
121
|
});
|
|
118
122
|
}
|
|
119
|
-
|
|
123
|
+
// A BOUNDED NUMBER OF STREAMS PER ADDRESS OR ACCOUNT (audit 2026-09-16, H1). The rate limiter
|
|
124
|
+
// above only paces how fast streams open; it never capped how many stay open.
|
|
125
|
+
const streamKey = openAccess ? `ip:${ip}` : `user:${user.user.id}`;
|
|
126
|
+
if (app.hub.countFor(streamKey) >= app.hub.limits.maxPerKey) {
|
|
127
|
+
return sendJson(req, res, 429, { error: { message: `too many open streams (${app.hub.limits.maxPerKey}) for this ${openAccess ? 'address' : 'account'}`, kind: 'ratelimited' } });
|
|
128
|
+
}
|
|
129
|
+
const client = app.hub.add(req, res, { user: user.user, nodeId: wantNode, key: streamKey });
|
|
120
130
|
app.log({ level: 'info', msg: `sse #${client.id} opened by ${user.user.username} (${ip})` });
|
|
121
131
|
req.on('close', () => app.log({ level: 'info', msg: `sse #${client.id} closed (${Math.round((Date.now() - started) / 1000)}s)` }));
|
|
122
132
|
return undefined;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
const match = matchRoute(compiled, req.method, path);
|
|
136
|
+
if (match?.badPath) return sendJson(req, res, 400, { error: { message: 'malformed percent-encoding in the path', kind: 'bad_request' } });
|
|
126
137
|
if (match?.methodMismatch) {
|
|
127
138
|
res.setHeader('Allow', [...new Set(compiled.filter((r) => r.re.test(path)).map((r) => r.method))].join(', '));
|
|
128
139
|
return sendJson(req, res, 405, { error: { message: `${req.method} is not allowed on ${path}`, kind: 'method' } });
|
|
@@ -309,7 +320,13 @@ function matchRoute(compiled, method, path) {
|
|
|
309
320
|
if (!m) continue;
|
|
310
321
|
if (r.method !== method) { methodMismatch = true; continue; }
|
|
311
322
|
const params = {};
|
|
312
|
-
|
|
323
|
+
// A malformed escape in a path parameter is the client's mistake, and answered as one: it used to
|
|
324
|
+
// throw here and come back as a 500 "internal error" (audit 2026-09-16, L13).
|
|
325
|
+
try {
|
|
326
|
+
r.names.forEach((n, i) => { params[n] = decodeURIComponent(m[i + 1]); });
|
|
327
|
+
} catch {
|
|
328
|
+
return { badPath: true };
|
|
329
|
+
}
|
|
313
330
|
return { route: r, params };
|
|
314
331
|
}
|
|
315
332
|
return methodMismatch ? { methodMismatch: true } : null;
|
package/server/http/sse.js
CHANGED
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
// are coalesced per client -- at most one snapshot in flight, latest wins -- and
|
|
11
11
|
// the event feed is batched. A client that cannot keep up sees a coarser stream,
|
|
12
12
|
// never an unbounded queue that takes the process down.
|
|
13
|
+
//
|
|
14
|
+
// THAT LAST SENTENCE WAS NOT TRUE UNTIL 2026-09-16 (audit H1). Coalescing only helps if nothing is
|
|
15
|
+
// written while the socket is full, and flush() wrote every pending frame regardless: `write()`
|
|
16
|
+
// noted `backpressured` and nothing ever read it. The reaper removed a client only if it had been
|
|
17
|
+
// sent zero bytes, which no client ever has. So a reader that stopped reading kept every snapshot
|
|
18
|
+
// queued in Node's socket buffer: 400 stalled connections took a test instance from 73 MB to 1.5 GB
|
|
19
|
+
// in three minutes. Now a backpressured client is sent nothing (its pending snapshot keeps being
|
|
20
|
+
// replaced, latest wins) until the socket drains; one whose buffer passes a ceiling, or that stays
|
|
21
|
+
// blocked past a deadline, is dropped; and one address holds a bounded number of streams.
|
|
22
|
+
export const SSE_LIMITS = Object.freeze({
|
|
23
|
+
maxBufferedBytes: 4 * 1024 * 1024, // well above one snapshot; a client this far behind is not reading
|
|
24
|
+
maxBlockedMs: 60_000, // blocked for a minute: an EventSource reconnects on its own
|
|
25
|
+
maxPerKey: 16, // streams per address (open mode) or per account
|
|
26
|
+
});
|
|
13
27
|
class Client {
|
|
14
28
|
constructor(res, user, id) {
|
|
15
29
|
this.res = res;
|
|
@@ -25,13 +39,17 @@ class Client {
|
|
|
25
39
|
this.alive = true;
|
|
26
40
|
this.lastWriteAt = Date.now();
|
|
27
41
|
this.nodeId = null;
|
|
42
|
+
this.key = null;
|
|
43
|
+
this.backpressured = false;
|
|
44
|
+
this.blockedSince = 0;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
get dead() { return !this.alive || this.res.writableEnded || this.res.destroyed; }
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
export class StreamHub {
|
|
34
|
-
constructor({ log = () => {} } = {}) {
|
|
51
|
+
constructor({ log = () => {}, limits = {} } = {}) {
|
|
52
|
+
this.limits = { ...SSE_LIMITS, ...limits };
|
|
35
53
|
this.clients = new Set();
|
|
36
54
|
this.log = log;
|
|
37
55
|
this.seq = 0;
|
|
@@ -39,10 +57,18 @@ export class StreamHub {
|
|
|
39
57
|
this.timer = null;
|
|
40
58
|
}
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
/** How many open streams share this key (an address in open mode, an account otherwise). */
|
|
61
|
+
countFor(key) {
|
|
62
|
+
let n = 0;
|
|
63
|
+
for (const c of this.clients) if (c.key === key && !c.dead) n += 1;
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
add(req, res, { user = null, nodeId = null, key = null } = {}) {
|
|
43
68
|
const id = ++this.seq;
|
|
44
69
|
const client = new Client(res, user, id);
|
|
45
70
|
client.nodeId = nodeId;
|
|
71
|
+
client.key = key;
|
|
46
72
|
res.writeHead(200, {
|
|
47
73
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
48
74
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
@@ -55,6 +81,11 @@ export class StreamHub {
|
|
|
55
81
|
req.on('close', close);
|
|
56
82
|
req.on('aborted', close);
|
|
57
83
|
res.on('error', close);
|
|
84
|
+
res.on('drain', () => {
|
|
85
|
+
client.backpressured = false;
|
|
86
|
+
client.blockedSince = 0;
|
|
87
|
+
this.flush();
|
|
88
|
+
});
|
|
58
89
|
if (!this.timer) this.startHeartbeat();
|
|
59
90
|
return client;
|
|
60
91
|
}
|
|
@@ -70,24 +101,35 @@ export class StreamHub {
|
|
|
70
101
|
const tick = () => {
|
|
71
102
|
for (const c of [...this.clients]) {
|
|
72
103
|
if (c.dead) { this.remove(c); continue; }
|
|
104
|
+
if (this.stalled(c)) continue;
|
|
73
105
|
// A comment frame keeps intermediaries from reaping an idle stream and
|
|
74
|
-
// lets us notice a dead socket on our own terms.
|
|
75
|
-
if (this.write(c, ': ping\n\n')
|
|
76
|
-
if (Date.now() - c.lastWriteAt > 120_000 && c.bytes === 0) this.remove(c);
|
|
77
|
-
}
|
|
106
|
+
// lets us notice a dead socket on our own terms. Never onto a full socket.
|
|
107
|
+
if (!c.backpressured) this.write(c, ': ping\n\n');
|
|
78
108
|
}
|
|
79
109
|
};
|
|
80
110
|
this.timer = setInterval(tick, this.heartbeatMs);
|
|
81
111
|
this.timer.unref?.();
|
|
82
112
|
}
|
|
83
113
|
|
|
114
|
+
/** Drop a client that is too far behind, or blocked too long; true if it was dropped. */
|
|
115
|
+
stalled(client) {
|
|
116
|
+
const buffered = client.res.writableLength ?? 0;
|
|
117
|
+
const blockedFor = client.backpressured && client.blockedSince ? Date.now() - client.blockedSince : 0;
|
|
118
|
+
if (buffered <= this.limits.maxBufferedBytes && blockedFor <= this.limits.maxBlockedMs) return false;
|
|
119
|
+
this.log({ level: 'warn', msg: `sse #${client.id} dropped: not reading (${Math.round(buffered / 1024)} KB buffered, blocked ${Math.round(blockedFor / 1000)}s)` });
|
|
120
|
+
this.remove(client);
|
|
121
|
+
try { client.res.destroy(); } catch { /* already gone */ }
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
84
125
|
write(client, frame) {
|
|
85
126
|
if (client.dead) return false;
|
|
86
127
|
try {
|
|
87
128
|
const ok = client.res.write(frame);
|
|
88
129
|
client.bytes += frame.length;
|
|
89
130
|
client.lastWriteAt = Date.now();
|
|
90
|
-
if (!ok) client.backpressured = true;
|
|
131
|
+
if (!ok && !client.backpressured) { client.backpressured = true; client.blockedSince = Date.now(); }
|
|
132
|
+
if (!ok) this.stalled(client);
|
|
91
133
|
return ok;
|
|
92
134
|
} catch (err) {
|
|
93
135
|
this.log({ level: 'debug', msg: `sse write failed: ${err.message}` });
|
|
@@ -142,6 +184,9 @@ export class StreamHub {
|
|
|
142
184
|
this.flushing = false;
|
|
143
185
|
for (const c of [...this.clients]) {
|
|
144
186
|
if (c.dead) { this.remove(c); continue; }
|
|
187
|
+
// A full socket gets nothing more: what is pending stays pending (and is replaced by
|
|
188
|
+
// newer state) until 'drain' flushes again.
|
|
189
|
+
if (c.backpressured) { this.stalled(c); continue; }
|
|
145
190
|
if (c.pendingSnapshot !== null) {
|
|
146
191
|
const snap = c.pendingSnapshot;
|
|
147
192
|
c.pendingSnapshot = null;
|
|
@@ -166,6 +211,7 @@ export class StreamHub {
|
|
|
166
211
|
perClient: [...this.clients].map((c) => ({
|
|
167
212
|
id: c.id, user: c.user?.username ?? null, seconds: Math.round((Date.now() - c.connectedAt) / 1000),
|
|
168
213
|
kb: Math.round(c.bytes / 1024), dropped: { ...c.dropped }, node: c.nodeId,
|
|
214
|
+
backpressured: c.backpressured, bufferedKb: Math.round((c.res.writableLength ?? 0) / 1024),
|
|
169
215
|
})),
|
|
170
216
|
};
|
|
171
217
|
}
|
package/server/main.js
CHANGED
|
@@ -26,8 +26,9 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf
|
|
|
26
26
|
|
|
27
27
|
export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
28
28
|
const cfg = loadConfig({ configFile });
|
|
29
|
-
|
|
30
|
-
await fsp.mkdir(cfg.
|
|
29
|
+
// owner-only where this creates them (audit 2026-09-16, L10); an existing directory keeps its mode
|
|
30
|
+
await fsp.mkdir(cfg.store.dir, { recursive: true, mode: 0o700 });
|
|
31
|
+
await fsp.mkdir(cfg.auth.dataDir, { recursive: true, mode: 0o700 });
|
|
31
32
|
|
|
32
33
|
const app = {
|
|
33
34
|
version: VERSION,
|
|
@@ -269,7 +270,12 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
269
270
|
// which are not secrets, and an audit trail full of [redacted] where the useful fields were is
|
|
270
271
|
// its own kind of failure.
|
|
271
272
|
const SECRETISH = /pass(word|phrase)?|secret|cookie|token|priv(ate)?_?key|seed|mnemonic|authorization|credential/i;
|
|
273
|
+
// AND CLAMP BY LENGTH (audit 2026-09-16, M6). A caller-chosen string went into the trail whole:
|
|
274
|
+
// a 900 KB RPC method name, sixty times over, rotated every retained file and took the sign-ins and
|
|
275
|
+
// user changes with it. No field the trail needs is anywhere near this long.
|
|
276
|
+
const AUDIT_STRING_MAX = 1024;
|
|
272
277
|
const redact = (v, depth = 0) => {
|
|
278
|
+
if (typeof v === 'string' && v.length > AUDIT_STRING_MAX) return `${v.slice(0, AUDIT_STRING_MAX)}…[${v.length - AUDIT_STRING_MAX} more chars]`;
|
|
273
279
|
if (v == null || depth > 6) return v;
|
|
274
280
|
if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
|
|
275
281
|
if (typeof v !== 'object') return v;
|
|
@@ -485,10 +491,14 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
|
485
491
|
heartbeat.unref?.();
|
|
486
492
|
buildIndex({
|
|
487
493
|
rpc, blocksDir: path.join(m.cfg.datadir, 'blocks'), out: dir, workers, pace,
|
|
494
|
+
// AN INTERRUPTED BUILD RESUMES (build.js, THE BUILD JOURNAL): where it resumed, or why the
|
|
495
|
+
// unfinished work was thrown away, is an event like the start
|
|
496
|
+
log: (text) => say(text),
|
|
488
497
|
onProgress: (p) => {
|
|
489
498
|
if (p.phase !== phase) { phase = p.phase; phaseAt = Date.now(); }
|
|
490
499
|
const elapsed = (Date.now() - phaseAt) / 1000;
|
|
491
|
-
|
|
500
|
+
// a resumed build starts the phase part way (`from`): the rate is this run's, not the earlier one's
|
|
501
|
+
const rate = elapsed > 0 && p.done > (p.from ?? 0) ? (p.done - (p.from ?? 0)) / elapsed : 0;
|
|
492
502
|
Object.assign(status, { phase: p.phase, done: p.done, total: p.total, rows: p.rows ?? status.rows, eta: rate > 0 && p.total > p.done ? HMS((p.total - p.done) / rate) : null });
|
|
493
503
|
lastProgressAt = Date.now();
|
|
494
504
|
if (Date.now() - lastFlag > 5000) {
|
package/server/rpc/allowlist.js
CHANGED
|
@@ -48,8 +48,33 @@ const ALLOW_PREFIXES = ['get', 'list', 'estimate', 'verify', 'estimat', 'help',
|
|
|
48
48
|
// transactions, which is a write in every way that matters.
|
|
49
49
|
const DENY_PREFIXES = ['generate', 'invalidate', 'reconsider', 'import', 'send', 'set', 'unload', 'load', 'sign'];
|
|
50
50
|
|
|
51
|
+
// EVERY WALLET RPC, READ-SHAPED OR NOT (audit 2026-09-16, M4). The `get` and `list` prefixes let
|
|
52
|
+
// `listdescriptors true` and `gethdkeys {"private":true}` through, and both return private keys
|
|
53
|
+
// from an unlocked wallet -- to anyone the console serves, which in open mode is anyone who can
|
|
54
|
+
// reach the port. The monitor has no use for a wallet, so the whole category is refused by name.
|
|
55
|
+
// The list is Bitcoin Core's own "== Wallet ==" section of `help` (Core 29/30, taken 2026-09-16),
|
|
56
|
+
// plus the legacy-wallet methods older nodes still answer.
|
|
57
|
+
export const WALLET_METHODS = new Set([
|
|
58
|
+
'abandontransaction', 'abortrescan', 'addhdkey', 'backupwallet', 'bumpfee', 'createwallet',
|
|
59
|
+
'createwalletdescriptor', 'encryptwallet', 'exportwatchonlywallet', 'getaddressesbylabel',
|
|
60
|
+
'getaddressinfo', 'getbalance', 'getbalances', 'gethdkeys', 'getnewaddress', 'getrawchangeaddress',
|
|
61
|
+
'getreceivedbyaddress', 'getreceivedbylabel', 'gettransaction', 'getwalletinfo', 'importdescriptors',
|
|
62
|
+
'importprunedfunds', 'keypoolrefill', 'listaddressgroupings', 'listdescriptors', 'listlabels',
|
|
63
|
+
'listlockunspent', 'listreceivedbyaddress', 'listreceivedbylabel', 'listsinceblock', 'listtransactions',
|
|
64
|
+
'listunspent', 'listwalletdir', 'listwallets', 'loadwallet', 'lockunspent', 'migratewallet',
|
|
65
|
+
'psbtbumpfee', 'removeprunedfunds', 'rescanblockchain', 'restorewallet', 'send', 'sendall', 'sendmany',
|
|
66
|
+
'sendtoaddress', 'setlabel', 'setwalletflag', 'signmessage', 'signrawtransactionwithwallet',
|
|
67
|
+
'simulaterawtransaction', 'unloadwallet', 'walletcreatefundedpsbt', 'walletdisplayaddress', 'walletlock',
|
|
68
|
+
'walletpassphrase', 'walletpassphrasechange', 'walletprocesspsbt',
|
|
69
|
+
// legacy (pre-descriptor) wallets
|
|
70
|
+
'dumpprivkey', 'dumpwallet', 'importprivkey', 'importaddress', 'importpubkey', 'importmulti',
|
|
71
|
+
'importwallet', 'sethdseed', 'upgradewallet', 'getunconfirmedbalance', 'listaccounts', 'getaccount',
|
|
72
|
+
'getaccountaddress', 'getaddressesbyaccount', 'getreceivedbyaccount', 'listreceivedbyaccount',
|
|
73
|
+
]);
|
|
74
|
+
|
|
51
75
|
export function classifyMethod(method) {
|
|
52
76
|
if (typeof method !== 'string' || !method) return { allowed: false, kind: 'unknown', reason: 'method name must be a string' };
|
|
77
|
+
if (WALLET_METHODS.has(method)) return { allowed: false, kind: 'wallet', reason: 'wallet RPCs are not exposed: some of them return private keys, and the monitor has no use for a wallet' };
|
|
53
78
|
if (DENY_EXACT.has(method)) return { allowed: false, kind: 'write-or-heavy', reason: 'this method mutates state or monopolises the node\'s single-threaded RPC server' };
|
|
54
79
|
for (const p of DENY_PREFIXES) if (method.startsWith(p) && !ALLOW_PREFIXES.includes(method)) return { allowed: false, kind: 'write', reason: `method starts with "${p}" and is treated as a mutation` };
|
|
55
80
|
if (method === 'help' || method === 'uptime' || method === 'stop') {
|
|
@@ -73,6 +98,7 @@ export const NODE_REFUSES = new Set([
|
|
|
73
98
|
export function allowlistSummary() {
|
|
74
99
|
return {
|
|
75
100
|
denyExactCount: DENY_EXACT.size,
|
|
101
|
+
walletDenied: WALLET_METHODS.size,
|
|
76
102
|
allowPrefixes: [...new Set(ALLOW_PREFIXES)].sort(),
|
|
77
103
|
denyPrefixes: [...new Set(DENY_PREFIXES)].sort(),
|
|
78
104
|
defaultDecision: 'deny',
|