blockyard 0.0.1 → 0.0.9
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 +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -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 +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -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 +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -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 +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -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 +20 -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 +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -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 +193 -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 +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -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/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Which address this process is willing to be reached on, and what to say when it
|
|
2
|
+
// can't be. Kept separate from the HTTP layer because the answers are boot-time
|
|
3
|
+
// facts about the machine, not about requests -- and because a failed bind used to
|
|
4
|
+
// surface as a raw Node stack trace, which tells you nothing about the one question
|
|
5
|
+
// that matters: which address did you ask for, and which ones does this box have?
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
export function networkInterfaces(over = null) {
|
|
9
|
+
return over ?? os.networkInterfaces();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Every unicast address the kernel actually has, as { name, address, family }.
|
|
13
|
+
export function localAddresses(ifaces = networkInterfaces()) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const [name, list] of Object.entries(ifaces ?? {})) {
|
|
16
|
+
for (const a of list ?? []) {
|
|
17
|
+
if (a.internal && a.family !== 'IPv4') continue;
|
|
18
|
+
out.push({ name, address: a.address, family: a.family, internal: !!a.internal });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hasAddress(addr, ifaces = networkInterfaces()) {
|
|
25
|
+
if (addr === '0.0.0.0' || addr === '::' || addr === '*' || addr == null) return true;
|
|
26
|
+
const target = String(addr).replace(/^\[|\]$/g, '');
|
|
27
|
+
const addrs = localAddresses(ifaces).map((a) => a.address.replace(/^\[|\]$/g, ''));
|
|
28
|
+
if (addrs.includes(target)) return true;
|
|
29
|
+
// `localhost` and 127.0.0.1 / ::1 are always bindable, present or not in the list.
|
|
30
|
+
if (target === 'localhost' || target === '127.0.0.1' || target === '::1') return true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Is the configured bind an address literal (or localhost)? A hostname here fails at
|
|
35
|
+
// listen() with a message about names, and after a reboot with a changed DNS it can
|
|
36
|
+
// fail at the worst possible moment, so it is worth refusing at load instead.
|
|
37
|
+
export function isBindableHost(host) {
|
|
38
|
+
if (host == null || host === '') return true;
|
|
39
|
+
const h = String(host).replace(/^\[|\]$/g, '');
|
|
40
|
+
if (h === 'localhost' || h === '0.0.0.0' || h === '::' || h === '*') return true;
|
|
41
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
|
|
42
|
+
return h.split('.').every((p) => Number(p) <= 255);
|
|
43
|
+
}
|
|
44
|
+
if (h.includes(':')) return /^[0-9a-fA-F:.%]+$/.test(h);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Split a bind configuration into addresses, and say which this machine can take.
|
|
51
|
+
*
|
|
52
|
+
* Multiple addresses are supported because one socket cannot express "LAN and the
|
|
53
|
+
* tunnel, but not the container bridges and not loopback" -- and because the
|
|
54
|
+
* single-address version of this locked the operator out: their browser arrives over
|
|
55
|
+
* the tailnet, and a LAN-only bind is unreachable from the tailnet (the tailnet does
|
|
56
|
+
* not advertise 192.0.2.0/24 here).
|
|
57
|
+
*
|
|
58
|
+
* Missing addresses are reported, not fatal: a tunnel interface that is not up yet at
|
|
59
|
+
* boot (tailscaled starting after the network) must not keep the monitor from
|
|
60
|
+
* serving the interfaces that ARE present. Only "none of them work" is fatal.
|
|
61
|
+
*/
|
|
62
|
+
export function planBinds(hosts, ifaces = networkInterfaces()) {
|
|
63
|
+
const list = (Array.isArray(hosts) ? hosts : String(hosts ?? '0.0.0.0').split(','))
|
|
64
|
+
.map((h) => String(h).trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
const bindable = [];
|
|
67
|
+
const missing = [];
|
|
68
|
+
for (const h of list) (hasAddress(h, ifaces) ? bindable : missing).push(h);
|
|
69
|
+
return { list, bindable, missing, noneUsable: bindable.length === 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A human-readable explanation of a failed or impossible bind.
|
|
74
|
+
*
|
|
75
|
+
* The point is not politeness. Binding to one address is a decision with casualties
|
|
76
|
+
* (loopback healthchecks, other interfaces, a DHCP address that moved), and an
|
|
77
|
+
* operator staring at `EADDRNOTAVAIL` should not have to reconstruct that.
|
|
78
|
+
*/
|
|
79
|
+
export function bindProblemMessage({ err, host, port, ifaces = networkInterfaces() }) {
|
|
80
|
+
const code = err?.code ?? '';
|
|
81
|
+
const have = localAddresses(ifaces).filter((a) => a.family === 'IPv4');
|
|
82
|
+
const listed = have.map((a) => `${a.address} (${a.name})`).join(', ') || 'no unicast IPv4 at all';
|
|
83
|
+
const where = 'config/local.json → server.host, or BLOCKYARD_BIND';
|
|
84
|
+
|
|
85
|
+
if (code === 'EADDRNOTAVAIL') {
|
|
86
|
+
return `cannot bind http://${host}:${port} — this machine has no address ${host}. `
|
|
87
|
+
+ `Addresses present: ${listed}. If the interface or its lease changed, update ${where} `
|
|
88
|
+
+ `(or set 0.0.0.0 to accept every interface, which serves whatever the box has, including tunnels).`;
|
|
89
|
+
}
|
|
90
|
+
if (code === 'EACCES') {
|
|
91
|
+
return `cannot bind ${host}:${port} — permission denied. Ports below 1024 need privileges; `
|
|
92
|
+
+ `use a high port (21000) or a reverse proxy in front.`;
|
|
93
|
+
}
|
|
94
|
+
if (code === 'EADDRINUSE') {
|
|
95
|
+
const same = have.some((a) => a.address === host);
|
|
96
|
+
return `cannot bind ${host}:${port} — something is already listening there. `
|
|
97
|
+
+ (same
|
|
98
|
+
? `The address is one of ours, so this is likely a second instance of this monitor: stop the other one (or point this one at another port).`
|
|
99
|
+
: `${host} is not this machine's own address, so a different process or a container owns it.`)
|
|
100
|
+
+ ` Address list: ${listed}.`;
|
|
101
|
+
}
|
|
102
|
+
return `cannot bind ${host}:${port}${code ? ` (${code})` : ''}${err?.message ? `: ${err.message}` : ''}. `
|
|
103
|
+
+ `Addresses present: ${listed}. Override with ${where}.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------- CIDR membership
|
|
107
|
+
//
|
|
108
|
+
// This replaces prefix-TEXT matching, which over-permitted. `ipAllowed` used to
|
|
109
|
+
// compare `net.split('::')[0]` against the start of the client's address string,
|
|
110
|
+
// so `2001:db8:1::/48` accepted `2001:db8:1f::1` -- an address 14 bits outside the
|
|
111
|
+
// network -- because "2001:db8:1f::1" starts with the characters "2001:db8:1".
|
|
112
|
+
// Text has no idea where a nibble ends. This version parses both families to
|
|
113
|
+
// bytes and compares bit by bit.
|
|
114
|
+
//
|
|
115
|
+
// Malformed input fails CLOSED here: an entry that cannot be parsed can never
|
|
116
|
+
// admit a client, and `server.allowCidrs` is validated at config load, so a typo
|
|
117
|
+
// stops the boot rather than quietly widening (or narrowing) the gate.
|
|
118
|
+
|
|
119
|
+
/** Parse an IPv4 or IPv6 literal to bytes. Returns {family, bytes} or null. */
|
|
120
|
+
export function parseIp(input) {
|
|
121
|
+
if (typeof input !== 'string') return null;
|
|
122
|
+
let s = input.trim().replace(/^\[|\]$/g, '');
|
|
123
|
+
const zone = s.indexOf('%');
|
|
124
|
+
if (zone >= 0) s = s.slice(0, zone); // %eth0 is a scope, not part of the address
|
|
125
|
+
if (!s) return null;
|
|
126
|
+
if (s.includes(':')) return parseIpv6(s);
|
|
127
|
+
return parseIpv4(s);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseIpv4(s) {
|
|
131
|
+
const parts = s.split('.');
|
|
132
|
+
if (parts.length !== 4) return null;
|
|
133
|
+
const bytes = [];
|
|
134
|
+
for (const p of parts) {
|
|
135
|
+
if (!/^\d{1,3}$/.test(p)) return null;
|
|
136
|
+
const n = Number(p);
|
|
137
|
+
if (n > 255) return null;
|
|
138
|
+
bytes.push(n);
|
|
139
|
+
}
|
|
140
|
+
return { family: 'ipv4', bytes: new Uint8Array(bytes) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseIpv6(s) {
|
|
144
|
+
if (s.includes(':::')) return null; // ':::' is not compression, it is a typo
|
|
145
|
+
// Trailing IPv4 form (`::ffff:192.0.2.7`) occupies the low 4 bytes.
|
|
146
|
+
const tail4 = /:\d{1,3}(?:\.\d{1,3}){3}$/.test(s);
|
|
147
|
+
let head = s;
|
|
148
|
+
let tailBytes = null;
|
|
149
|
+
if (tail4) {
|
|
150
|
+
const i = s.lastIndexOf(':');
|
|
151
|
+
const v4 = parseIpv4(s.slice(i + 1));
|
|
152
|
+
if (!v4) return null;
|
|
153
|
+
tailBytes = v4.bytes;
|
|
154
|
+
head = s.slice(0, i + 1) + '0'; // placeholder group, overwritten below
|
|
155
|
+
}
|
|
156
|
+
const halves = head.split('::');
|
|
157
|
+
if (halves.length > 2) return null;
|
|
158
|
+
const expand = (chunk) => {
|
|
159
|
+
if (chunk === '') return [];
|
|
160
|
+
const groups = chunk.split(':').filter((g) => g !== '');
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const g of groups) {
|
|
163
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
|
|
164
|
+
out.push(Number.parseInt(g, 16));
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
let groups;
|
|
169
|
+
if (halves.length === 2) {
|
|
170
|
+
const left = expand(halves[0]);
|
|
171
|
+
const right = expand(halves[1]);
|
|
172
|
+
if (!left || !right) return null;
|
|
173
|
+
if (left.length + right.length > 7) return null; // '::' must stand for >=1 group
|
|
174
|
+
groups = [...left, ...new Array(8 - left.length - right.length).fill(0), ...right];
|
|
175
|
+
} else {
|
|
176
|
+
const only = expand(halves[0]);
|
|
177
|
+
if (!only) return null;
|
|
178
|
+
if (only.length !== 8) return null; // no '::' means all eight groups are required
|
|
179
|
+
groups = only;
|
|
180
|
+
}
|
|
181
|
+
const bytes = new Uint8Array(16);
|
|
182
|
+
groups.forEach((g, i) => { bytes[i * 2] = (g >> 8) & 0xff; bytes[i * 2 + 1] = g & 0xff; });
|
|
183
|
+
if (tailBytes) bytes.set(tailBytes, 12);
|
|
184
|
+
return { family: 'ipv6', bytes };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parse one allowlist entry: a bare address (implies /32 or /128) or `addr/bits`.
|
|
189
|
+
* Returns {ok:true, family, bytes, bits, text} or {ok:false, reason}.
|
|
190
|
+
*/
|
|
191
|
+
export function parseCidr(entry) {
|
|
192
|
+
const text = String(entry ?? '').trim();
|
|
193
|
+
if (!text) return { ok: false, reason: 'empty entry' };
|
|
194
|
+
const slash = text.indexOf('/');
|
|
195
|
+
const addrText = slash < 0 ? text : text.slice(0, slash);
|
|
196
|
+
const parsed = parseIp(addrText);
|
|
197
|
+
if (!parsed) return { ok: false, reason: `"${addrText}" is not an IPv4 or IPv6 address` };
|
|
198
|
+
const full = parsed.family === 'ipv4' ? 32 : 128;
|
|
199
|
+
let bits = full;
|
|
200
|
+
if (slash >= 0) {
|
|
201
|
+
const raw = text.slice(slash + 1);
|
|
202
|
+
if (!/^\d{1,3}$/.test(raw)) return { ok: false, reason: `"${raw}" is not a prefix length` };
|
|
203
|
+
bits = Number(raw);
|
|
204
|
+
if (bits > full) return { ok: false, reason: `/${bits} is wider than an ${parsed.family} address (${full} bits)` };
|
|
205
|
+
}
|
|
206
|
+
return { ok: true, family: parsed.family, bytes: parsed.bytes, bits, text };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Bit-exact membership of one address in one network. */
|
|
210
|
+
export function inNetwork(ipBytes, netBytes, bits) {
|
|
211
|
+
if (ipBytes.length !== netBytes.length) return false; // v4 never matches a v6 net, and vice versa
|
|
212
|
+
for (let i = 0; i < bits; i += 8) {
|
|
213
|
+
const take = Math.min(8, bits - i);
|
|
214
|
+
const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
|
|
215
|
+
if ((ipBytes[i / 8] & mask) !== (netBytes[i / 8] & mask)) return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Is `ip` admitted by `cidrs`? Empty/absent list admits everything (the gate is
|
|
222
|
+
* opt-in). Returns a decision with the reason, because "403 from this address"
|
|
223
|
+
* with no explanation costs an afternoon: the message names the entry that
|
|
224
|
+
* matched, or that the list holds only families the client is not in.
|
|
225
|
+
*/
|
|
226
|
+
export function ipDecision(ip, cidrs = []) {
|
|
227
|
+
if (!cidrs.length) return { allowed: true, reason: 'no allowCidrs configured' };
|
|
228
|
+
const parsedIp = parseIp(ip);
|
|
229
|
+
if (!parsedIp) return { allowed: false, reason: `client address "${ip}" could not be parsed, so nothing can be admitted` };
|
|
230
|
+
const bad = [];
|
|
231
|
+
let familyMismatch = true;
|
|
232
|
+
for (const entry of cidrs) {
|
|
233
|
+
const net = parseCidr(entry);
|
|
234
|
+
if (!net.ok) { bad.push(`${entry} (${net.reason})`); continue; }
|
|
235
|
+
if (net.family !== parsedIp.family) continue;
|
|
236
|
+
familyMismatch = false;
|
|
237
|
+
if (inNetwork(parsedIp.bytes, net.bytes, net.bits)) {
|
|
238
|
+
return { allowed: true, matched: net.text, reason: `matched ${net.text}` };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const why = bad.length
|
|
242
|
+
? `malformed entries are never permissive: ${bad.join('; ')}`
|
|
243
|
+
: familyMismatch
|
|
244
|
+
? `the allowlist holds only ${[...new Set(cidrs.map((c) => parseCidr(c).family).filter(Boolean))].join('/') || 'nothing parseable'}; the client is ${parsedIp.family}`
|
|
245
|
+
: `not inside any of: ${cidrs.join(', ')}`;
|
|
246
|
+
return { allowed: false, reason: why, malformed: bad };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Boolean form, kept for call sites that only branch. */
|
|
250
|
+
export function ipAllowed(ip, cidrs = []) {
|
|
251
|
+
return ipDecision(ip, cidrs).allowed;
|
|
252
|
+
}
|
|
253
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Which RPCs the web UI may call. Default DENY.
|
|
2
|
+
//
|
|
3
|
+
// A prefix rule like "starts with get" would be a hole, not a guard: this node's
|
|
4
|
+
// own docs (docs/RPC_LIVE_NODE.md) record that `getnewaddress` and
|
|
5
|
+
// `getrawchangeaddress` derive and persist new wallet keys, and that
|
|
6
|
+
// `rescanblockchain` blocks every other RPC for its duration on a server that
|
|
7
|
+
// services one connection at a time. So the rules are:
|
|
8
|
+
//
|
|
9
|
+
// 1. an explicit deny list of mutating and expensive methods, checked first;
|
|
10
|
+
// 2. then an allow list of read-shaped prefixes;
|
|
11
|
+
// 3. anything unknown is denied, and the reply says what to change to allow it.
|
|
12
|
+
//
|
|
13
|
+
// Node write operations are not in this file at all: they live behind
|
|
14
|
+
// config.actions.allow and are audited. See http/api.js action routes.
|
|
15
|
+
|
|
16
|
+
// Mutating, wallet-changing, or file-writing. Checked before the allow prefixes.
|
|
17
|
+
const DENY_EXACT = new Set([
|
|
18
|
+
// wallet key/materialization -- "get" prefix notwithstanding
|
|
19
|
+
'getnewaddress', 'getrawchangeaddress', 'keypoolrefill', 'addhdkey',
|
|
20
|
+
// spends and broadcasts
|
|
21
|
+
'sendtoaddress', 'sendmany', 'send', 'sendall', 'sendrawtransaction', 'submitpackage',
|
|
22
|
+
'walletcreatefundedpsbt', 'fundrawtransaction', 'bumpfee', 'psbtbumpfee', 'signrawtransactionwithwallet',
|
|
23
|
+
// wallet state
|
|
24
|
+
'walletpassphrase', 'walletlock', 'walletpassphrasechange', 'encryptwallet', 'setlabel',
|
|
25
|
+
'importprivkey', 'importaddress', 'importpublickey', 'importdescriptors', 'importmempool',
|
|
26
|
+
'importmulti', 'importprunedfunds', 'removeprunedfunds', 'createwallet', 'loadwallet',
|
|
27
|
+
'unloadwallet', 'restorewallet', 'migratewallet', 'setwalletflag', 'backupwallet',
|
|
28
|
+
'exportwatchonlywallet', 'abandontransaction', 'lockunspent', 'sethdseed', 'settxfee',
|
|
29
|
+
'signmessage', 'signrawtransactionwithkey', 'walletprocesspsbt', 'descriptorprocesspsbt',
|
|
30
|
+
// peer / network control (the node documents these as worker-channel mutators)
|
|
31
|
+
'addnode', 'removeaddednode', 'disconnectnode', 'setban', 'clearbanned', 'setnetworkactive', 'ping',
|
|
32
|
+
// chain / storage mutators
|
|
33
|
+
'invalidateblock', 'reconsiderblock', 'preciousblock', 'pruneblockchain', 'submitblock',
|
|
34
|
+
'submitheader', 'stop', 'savemempool', 'dumptxoutset', 'loadtxoutset', 'simulateutxo',
|
|
35
|
+
// heavy reads that monopolise a single-threaded RPC server
|
|
36
|
+
'rescanblockchain', 'scanblocks', 'scantxoutset', 'getdescriptoractivity', 'verifychain',
|
|
37
|
+
// can switch logging off; the node refuses the mutating form anyway
|
|
38
|
+
'logging',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Bitcoin Core's own read verbs, and nothing else: an earlier node this was written against
|
|
42
|
+
// namespaced its own commands with a vendor prefix, and those prefixes were admitted here by
|
|
43
|
+
// read verb. That node is not supported (2026-09-14), and its prefixes are gone with it -- a
|
|
44
|
+
// prefix nobody uses is a hole waiting for a name.
|
|
45
|
+
const ALLOW_PREFIXES = ['get', 'list', 'estimate', 'verify', 'estimat', 'help', 'uptime', 'decoderaw', 'decodescript', 'createraw', 'analyzepsbt', 'decodepsbt', 'convertbits', 'getrpcinfo'];
|
|
46
|
+
|
|
47
|
+
// Explicitly not allowed even though they look read-shaped: they build or sign
|
|
48
|
+
// transactions, which is a write in every way that matters.
|
|
49
|
+
const DENY_PREFIXES = ['generate', 'invalidate', 'reconsider', 'import', 'send', 'set', 'unload', 'load', 'sign'];
|
|
50
|
+
|
|
51
|
+
export function classifyMethod(method) {
|
|
52
|
+
if (typeof method !== 'string' || !method) return { allowed: false, kind: 'unknown', reason: 'method name must be a string' };
|
|
53
|
+
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
|
+
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
|
+
if (method === 'help' || method === 'uptime' || method === 'stop') {
|
|
56
|
+
return method === 'stop'
|
|
57
|
+
? { allowed: false, kind: 'write', reason: 'stop shuts the node down; not exposed to the web UI' }
|
|
58
|
+
: { allowed: true, kind: 'read' };
|
|
59
|
+
}
|
|
60
|
+
for (const p of ALLOW_PREFIXES) if (method.startsWith(p)) return { allowed: true, kind: 'read' };
|
|
61
|
+
return { allowed: false, kind: 'unknown', reason: 'not recognised as a read-only method; add it to server/rpc/allowlist.js if this is wrong' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Methods the node itself documents as refusing (or as needing the worker
|
|
65
|
+
// channel). Surfaced so the UI can label a refusal as expected rather than as a
|
|
66
|
+
// monitor bug. Lifted from docs/RPC_LIVE_NODE.md's refusal catalogue.
|
|
67
|
+
export const NODE_REFUSES = new Set([
|
|
68
|
+
'loadtxoutset', 'getopenrpcinfo', 'rpc.discover', 'exportasmap', 'enumeratesigners',
|
|
69
|
+
'walletdisplayaddress', 'getmempoolcluster', 'getblockfrompeer', 'preciousblock',
|
|
70
|
+
'pruneblockchain', 'submitheader', 'getblockfilter',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
export function allowlistSummary() {
|
|
74
|
+
return {
|
|
75
|
+
denyExactCount: DENY_EXACT.size,
|
|
76
|
+
allowPrefixes: [...new Set(ALLOW_PREFIXES)].sort(),
|
|
77
|
+
denyPrefixes: [...new Set(DENY_PREFIXES)].sort(),
|
|
78
|
+
defaultDecision: 'deny',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Actions the operator can explicitly opt into. Each must appear in
|
|
83
|
+
// config.actions.allow to be reachable at all.
|
|
84
|
+
export const ACTIONS = {
|
|
85
|
+
broadcast: {
|
|
86
|
+
method: 'sendrawtransaction',
|
|
87
|
+
label: 'Broadcast a raw transaction',
|
|
88
|
+
role: 'operator',
|
|
89
|
+
args: ['hexstring', 'maxfeerate?'],
|
|
90
|
+
note: 'Pushes a signed transaction to the node, which relays it to every live peer leg. Irreversible once relayed.',
|
|
91
|
+
},
|
|
92
|
+
savemempool: {
|
|
93
|
+
method: 'savemempool',
|
|
94
|
+
label: 'Persist the mempool to mempool.dat',
|
|
95
|
+
role: 'operator',
|
|
96
|
+
args: [],
|
|
97
|
+
note: 'Writes mempool.dat in Core\'s format. The node refuses if persistence is disabled.',
|
|
98
|
+
},
|
|
99
|
+
testmempoolaccept: {
|
|
100
|
+
method: 'testmempoolaccept',
|
|
101
|
+
label: 'Dry-run a transaction against mempool policy',
|
|
102
|
+
role: 'viewer',
|
|
103
|
+
args: ['rawtxs', 'maxfeerate?'],
|
|
104
|
+
note: 'Runs the identical consensus and policy validation, stopping at the commit boundary. Accepts a package.',
|
|
105
|
+
},
|
|
106
|
+
verifychain_l1: {
|
|
107
|
+
method: 'verifychain',
|
|
108
|
+
label: 'Verify block data (checklevel <= 2)',
|
|
109
|
+
role: 'admin',
|
|
110
|
+
args: ['checklevel', 'checkdepth'],
|
|
111
|
+
fixed: [2, 6],
|
|
112
|
+
note: 'Levels 0-2 re-read and re-hash block data and check the merkle root. Levels 3-4 are refused by the node because they would disconnect and reconnect blocks.',
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function actionAllowed(cfg, name, role) {
|
|
117
|
+
const def = ACTIONS[name];
|
|
118
|
+
if (!def) return { ok: false, reason: `unknown action "${name}"` };
|
|
119
|
+
if (!cfg.actions.enabled) return { ok: false, reason: 'node actions are disabled (set BLOCKYARD_ENABLE_ACTIONS=1 and list BLOCKYARD_ACTIONS)' };
|
|
120
|
+
// With accounts off there is no role to check, so the role gate below is vacuous
|
|
121
|
+
// and every listed action would be reachable by whoever can open a socket. Writes
|
|
122
|
+
// therefore need a second, explicit acknowledgement in open mode.
|
|
123
|
+
if (!cfg.auth.enabled && !cfg.actions.allowWritesWithoutAuth) {
|
|
124
|
+
return { ok: false, reason: 'accounts are off, so there is no identity to hold a node write accountable (enable BLOCKYARD_AUTH=1, or BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1 deliberately)' };
|
|
125
|
+
}
|
|
126
|
+
if (!cfg.actions.allow.includes(name)) return { ok: false, reason: `"${name}" is not in config.actions.allow` };
|
|
127
|
+
const rank = { viewer: 0, operator: 1, admin: 2 };
|
|
128
|
+
if ((rank[role] ?? -1) < (rank[def.role] ?? 99)) return { ok: false, reason: `action "${name}" needs role ${def.role}` };
|
|
129
|
+
return { ok: true, def };
|
|
130
|
+
}
|