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
package/server/config.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
// Configuration. Defaults are wired to the actual deployment discovered on this
|
|
2
|
+
// box (see README "Where the defaults come from"), so `npm start` works with no
|
|
3
|
+
// setup. Every value is overridable by env var or by config/local.json.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import fsp from 'node:fs/promises';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { isBindableHost, planBinds, parseCidr } from './netinfo.js';
|
|
10
|
+
|
|
11
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
|
|
13
|
+
const HERE_DOC = `
|
|
14
|
+
Defaults are Bitcoin Core's own, so \`npm start\` works against a stock local node:
|
|
15
|
+
node RPC 127.0.0.1:8332 (Core's mainnet default; testnet 18332, signet 38332)
|
|
16
|
+
cookie <datadir>/<chain>/.cookie (regenerated each boot, deleted on stop)
|
|
17
|
+
datadir ~/.bitcoin
|
|
18
|
+
systemd bitcoind.service
|
|
19
|
+
log <datadir>/debug.log
|
|
20
|
+
NOTE a node that serves RPC on a NON-default port is the single most common reason
|
|
21
|
+
this monitor reports the node offline with ECONNREFUSED while the node is healthy.
|
|
22
|
+
Measured twice on the machine this was built against, where an rpcport= line was lost
|
|
23
|
+
to a config cleanup: check the node's own log for its "JSON-RPC server on ..." line
|
|
24
|
+
before believing the report. RPC can also bind well after systemd says "running", and
|
|
25
|
+
chain RPCs can block for tens of seconds after that -- see MEASUREMENTS 18.
|
|
26
|
+
`.trim();
|
|
27
|
+
|
|
28
|
+
const DEFAULTS = {
|
|
29
|
+
server: {
|
|
30
|
+
// Multi-user means the LAN has to reach it, so it binds broadly by default.
|
|
31
|
+
// Set BLOCKYARD_BIND=127.0.0.1 to keep it on this machine only.
|
|
32
|
+
host: '0.0.0.0',
|
|
33
|
+
// 21000 (operator, 2026-09-13: "make default web port 21000 for access"). It was 8088, which
|
|
34
|
+
// sits in the range every other monitor on a box reaches for; this one is ours.
|
|
35
|
+
port: 21000,
|
|
36
|
+
// Defense in depth: only these CIDRs may connect. Empty = any.
|
|
37
|
+
allowCidrs: [],
|
|
38
|
+
trustProxy: false,
|
|
39
|
+
// TLS is off unless both files are named, and then it is on for every listener.
|
|
40
|
+
// It stays opt-in because this box is a LAN monitor whose certificate has no
|
|
41
|
+
// issuer: a self-signed cert produces a browser warning on every address change,
|
|
42
|
+
// and the alternative already documented (an SSH tunnel to 127.0.0.1, or a
|
|
43
|
+
// reverse proxy that owns the cert) is better on a machine you control.
|
|
44
|
+
// What was missing until 2026-09-09 was the option at all -- serving a session
|
|
45
|
+
// cookie and every RPC reply over plain HTTP on a LAN is not a gap you get to
|
|
46
|
+
// call "documented, therefore fine".
|
|
47
|
+
tls: {
|
|
48
|
+
cert: null, // PEM; BLOCKYARD_TLS_CERT
|
|
49
|
+
key: null, // PEM; BLOCKYARD_TLS_KEY
|
|
50
|
+
// Sent over TLS responses only. Two days, not the usual year: a LAN address
|
|
51
|
+
// can be reissued to something else, and HSTS is the header that cannot be
|
|
52
|
+
// unsent. Deliberately no includeSubDomains and no preload.
|
|
53
|
+
hstsMs: 172_800_000,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
// Production only, by decision -- see "Why the benchmark node is not monitored"
|
|
57
|
+
// at the bottom of this block, and the measurements in docs/MEASUREMENTS.md.
|
|
58
|
+
//
|
|
59
|
+
// Multi-node support is intact (see the node switcher, per-node series rings, and
|
|
60
|
+
// the fact that this is an array): add entries in config/local.json to bring other
|
|
61
|
+
// nodes back. What is no longer default is watching a benchmark from the same box
|
|
62
|
+
// that runs it.
|
|
63
|
+
//
|
|
64
|
+
// A node whose datadir is absent is skipped at boot (see main.js) rather than
|
|
65
|
+
// failing, so a datadir that gets cleaned up does not break startup.
|
|
66
|
+
nodes: [
|
|
67
|
+
{
|
|
68
|
+
// Bitcoin Core's own mainnet defaults (operator, 2026-09-13: a Core-centric release,
|
|
69
|
+
// "meant to be pointed at your Umbrel nodes or local Bitcoin Nodes"). A deployment
|
|
70
|
+
// that differs says so in config/local.json or through BLOCKYARD_NODE_* -- both are
|
|
71
|
+
// applied over these, so nothing here has to be right for everyone.
|
|
72
|
+
id: 'main',
|
|
73
|
+
label: 'Bitcoin Core (mainnet)',
|
|
74
|
+
rpcUrl: 'http://127.0.0.1:8332',
|
|
75
|
+
datadir: '/home/bitcoin/.bitcoin',
|
|
76
|
+
chainHint: 'main',
|
|
77
|
+
cookieFile: null, // derived from datadir+chainHint when null
|
|
78
|
+
rpcUser: null,
|
|
79
|
+
rpcPassword: null,
|
|
80
|
+
logFile: '/home/bitcoin/.bitcoin/debug.log',
|
|
81
|
+
systemdUnit: 'bitcoind.service',
|
|
82
|
+
color: '#f7931a',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
// Why the benchmark node is not monitored (was `bench`, removed 2026-09-08).
|
|
86
|
+
//
|
|
87
|
+
// Not because the data was wrong. Because on this box the observation changes the
|
|
88
|
+
// thing observed and degrades the thing that matters:
|
|
89
|
+
//
|
|
90
|
+
// 1. Contention with the thing we actually care about. The node's RPC server
|
|
91
|
+
// services ONE connection at a time on one thread. Every poll of the bench
|
|
92
|
+
// node is time the bench cannot spend on the benchmark, and a monitor that
|
|
93
|
+
// shares the box with a benchmark is a load generator wearing a label.
|
|
94
|
+
// main.js already refuses to let `npm run dev` or smoke.sh touch the real
|
|
95
|
+
// bench node for this reason; running it as a monitored node was the same
|
|
96
|
+
// mistake with better branding.
|
|
97
|
+
// 2. It could not be read reliably anyway. Measured in one hour of monitoring:
|
|
98
|
+
// RPC average latency ~18-32 s, 90 s timeouts on the fast and slow tiers,
|
|
99
|
+
// 25 failed tier runs, and nine-plus restarts by its harness (each looking
|
|
100
|
+
// like an outage). Production, same monitor, same code: 0 errors, max 22 ms.
|
|
101
|
+
// 3. It contaminated the production charts. Un-tagged series rings mixed 2,308
|
|
102
|
+
// production rows with 1,816 bench rows in one line (MEASUREMENTS 20). The
|
|
103
|
+
// rings are per-node now, but the lesson stands: while both were displayed,
|
|
104
|
+
// at least one chart was not a fact about the node it was labelled with.
|
|
105
|
+
//
|
|
106
|
+
// To watch a benchmark again, put this in config/local.json (gitignored) -- the
|
|
107
|
+
// node switcher, the per-node rings and the picker all already support it:
|
|
108
|
+
//
|
|
109
|
+
// { "nodes": [
|
|
110
|
+
// { "id": "main", "label": "Bitcoin Core (mainnet)",
|
|
111
|
+
// "rpcUrl": "http://127.0.0.1:8332",
|
|
112
|
+
// "datadir": "/home/bitcoin/.bitcoin", "chainHint": "main",
|
|
113
|
+
// "logFile": "/home/bitcoin/.bitcoin/debug.log" },
|
|
114
|
+
// { "id": "bench", "label": "Bench node (IBD / benchmark)",
|
|
115
|
+
// "rpcUrl": "http://127.0.0.1:8461",
|
|
116
|
+
// "datadir": "/mnt/2tbssd/bench/data", "chainHint": "main",
|
|
117
|
+
// "logFile": "/mnt/2tbssd/bench/console.log",
|
|
118
|
+
// "logStaleMs": 600000, "optional": true }
|
|
119
|
+
// ] }
|
|
120
|
+
//
|
|
121
|
+
// Two caveats recorded while it was wired, because they will bite whoever pastes
|
|
122
|
+
// that in: the benchmark's real log is the run directory's `console.log`, NOT
|
|
123
|
+
// `<datadir>/main/debug.log` (a 144-byte stub -- three "node start" lines -- which
|
|
124
|
+
// is what the monitor tailed for two hours while reporting nothing); and that file
|
|
125
|
+
// is block-buffered, so lines can sit frozen for minutes and then arrive in a
|
|
126
|
+
// burst. `log-silent` distinguishes the two by quoting the chain delta it saw.
|
|
127
|
+
/* previous second entry, kept readable rather than silently deleted:
|
|
128
|
+
{
|
|
129
|
+
id: 'bench',
|
|
130
|
+
label: 'Bench node (IBD / benchmark)',
|
|
131
|
+
rpcUrl: 'http://127.0.0.1:8461',
|
|
132
|
+
datadir: '/mnt/2tbssd/bench/data',
|
|
133
|
+
chainHint: 'main',
|
|
134
|
+
logFile: '/mnt/2tbssd/bench/console.log',
|
|
135
|
+
logStaleMs: 600000,
|
|
136
|
+
optional: true,
|
|
137
|
+
},
|
|
138
|
+
*/
|
|
139
|
+
rpc: {
|
|
140
|
+
// The node's RPC server accepts and services ONE connection at a time on a
|
|
141
|
+
// single thread (docs/RPC_LIVE_NODE.md, slice 11). One browser tab polling
|
|
142
|
+
// eight methods is polite; forty tabs is a denial of service against our own
|
|
143
|
+
// node. So: one in-flight request globally, a floor between requests, and
|
|
144
|
+
// every poll tier sized so the node is never the bottleneck for itself.
|
|
145
|
+
maxInFlight: 1,
|
|
146
|
+
minIntervalMs: 250,
|
|
147
|
+
// Measured on this box: a bare getblockcount against the bench node took
|
|
148
|
+
// 40.4s while it was doing initial block download, and 7s once against the
|
|
149
|
+
// synced node under benchmark load. A 20s timeout would have declared a
|
|
150
|
+
// healthy-but-busy node unreachable, so the ceiling is well above that and
|
|
151
|
+
// the tier cadence adapts instead (see monitor.effectiveTierMs).
|
|
152
|
+
timeoutMs: 90000,
|
|
153
|
+
heavyTimeoutMs: 300000,
|
|
154
|
+
// A poll answer that arrives later than this describes a moment that has
|
|
155
|
+
// already passed; it is dropped rather than shown as current state.
|
|
156
|
+
staleDropMs: 12000,
|
|
157
|
+
// Above this average latency the UI says the node is slow rather than
|
|
158
|
+
// implying the monitor is broken.
|
|
159
|
+
slowLatencyMs: 5000,
|
|
160
|
+
breakerThreshold: 3, // consecutive failures before we back off
|
|
161
|
+
breakerCooldownMs: 30000,
|
|
162
|
+
// Hard ceiling on RPC calls/second we will issue, whatever the tiers ask for.
|
|
163
|
+
maxRatePerSec: 4,
|
|
164
|
+
},
|
|
165
|
+
poll: {
|
|
166
|
+
fastMs: 4000, // chaininfo, mempoolinfo, connections, nettotals, uptime
|
|
167
|
+
midMs: 15000, // mining info, fee estimates, chain tips, mempool ids
|
|
168
|
+
// The verbose mempool read has its own tier since 2026-09-11 (operator: "Faster
|
|
169
|
+
// refresh"): it measured 0.144 s for ~19k entries on this node, cheap next to
|
|
170
|
+
// gettxoutsetinfo, so it no longer waits on the heavy minute.
|
|
171
|
+
poolMs: 20000, // mempool verbose (feeds the block-space viewer and the mempool map)
|
|
172
|
+
slowMs: 60000, // indexes, txoutset, chaintxstats
|
|
173
|
+
rareMs: 900000, // peerinfo, deployment info, rpc info
|
|
174
|
+
blockBackfill: 30, // blocks of history to backfill at startup
|
|
175
|
+
},
|
|
176
|
+
store: {
|
|
177
|
+
dir: null, // resolved below; JSONL + snapshots
|
|
178
|
+
retentionHours: 72,
|
|
179
|
+
ringCapacity: 20000,
|
|
180
|
+
maxEventLog: 5000,
|
|
181
|
+
snapshotEveryMs: 120000,
|
|
182
|
+
// The in-memory height -> block map, kept bounded so long-range analytics come
|
|
183
|
+
// from the rings and not from an unbounded Map. Measured on 2026-09-09: 12,000
|
|
184
|
+
// rows cost 3.6 MB of heap (~310 B/row, test/monitor-shapes.test.js). The old
|
|
185
|
+
// 3,000-row cap was a round number rather than a budget, and it evicted blocks
|
|
186
|
+
// that the 72 h retention would happily have kept -- for ~0.9 MB.
|
|
187
|
+
blockMapCap: 12000,
|
|
188
|
+
// audit.jsonl rotation. The file records logins, RPC calls and action results;
|
|
189
|
+
// it holds no credentials, and it used to grow forever on a box that has filled
|
|
190
|
+
// its disk before -- at which point the failure is not "no audit" but "no node",
|
|
191
|
+
// because the monitor cannot write snapshots either.
|
|
192
|
+
auditMaxBytes: 8 * 1024 * 1024,
|
|
193
|
+
auditKeep: 5,
|
|
194
|
+
},
|
|
195
|
+
auth: {
|
|
196
|
+
// OPEN BY DEFAULT, like a block explorer: anyone who can reach the listen
|
|
197
|
+
// addresses reads the dashboard with no account. This is a posture decision, not
|
|
198
|
+
// a convenience -- see README "Open by default" and the boot warning, which names
|
|
199
|
+
// the addresses this leaves readable.
|
|
200
|
+
//
|
|
201
|
+
// What "open" is bounded by, in server/http/server.js:
|
|
202
|
+
// * the anonymous role is `viewer` and the ceiling is not configurable; user
|
|
203
|
+
// administration, the audit trail and password changes stay 403;
|
|
204
|
+
// * node writes are refused outright in open mode unless
|
|
205
|
+
// actions.allowWritesWithoutAuth says so explicitly (config load is fatal
|
|
206
|
+
// otherwise, because "open monitor + enabled writes" is a combination nobody
|
|
207
|
+
// should discover by accident);
|
|
208
|
+
// * rate limits key on the IP, so one noisy tab cannot spend everyone's bucket.
|
|
209
|
+
//
|
|
210
|
+
// Set BLOCKYARD_AUTH=1 (or auth.enabled in config/local.json) for accounts, roles,
|
|
211
|
+
// sessions, CSRF and the audit trail-by-user.
|
|
212
|
+
enabled: false,
|
|
213
|
+
dataDir: null,
|
|
214
|
+
// THE LONG ONE IS THE ABSOLUTE LIFETIME, the short one the idle ceiling -- which is the way
|
|
215
|
+
// round the names read, and the opposite of what shipped until 2026-09-13. With an 8 h
|
|
216
|
+
// absolute and a 72 h idle ceiling, the idle check in sessions.js could never fire: nothing
|
|
217
|
+
// lived long enough to be 72 h idle, so a session was 8 h whatever you did. Found in an audit;
|
|
218
|
+
// docs/SECURITY.md described the intended relationship, not the one in force.
|
|
219
|
+
sessionTtlMs: 72 * 3600 * 1000, // absolute: a session dies 72 h after sign-in, active or not
|
|
220
|
+
idleTtlMs: 8 * 3600 * 1000, // idle: 8 h without a request and it is gone
|
|
221
|
+
scrypt: { N: 16384, r: 8, p: 1, keylen: 32 },
|
|
222
|
+
minPasswordChars: 12,
|
|
223
|
+
loginMaxAttempts: 8,
|
|
224
|
+
loginWindowMs: 300000,
|
|
225
|
+
lockoutMs: 600000,
|
|
226
|
+
cookieName: 'blockyard_sid',
|
|
227
|
+
secureCookie: false, // forced true at boot when TLS is on
|
|
228
|
+
},
|
|
229
|
+
actions: {
|
|
230
|
+
// Anything that can change node or machine state is off unless explicitly
|
|
231
|
+
// enabled AND role-gated. Reads are the default; the UI says so.
|
|
232
|
+
enabled: false,
|
|
233
|
+
allow: [], // e.g. ['broadcast','savemempool']
|
|
234
|
+
requireAdmin: true,
|
|
235
|
+
// Separate acknowledgement: with accounts off there is no role to check, so an
|
|
236
|
+
// enabled write would be reachable by anyone who can open a socket.
|
|
237
|
+
allowWritesWithoutAuth: false,
|
|
238
|
+
},
|
|
239
|
+
log: {
|
|
240
|
+
level: 'info',
|
|
241
|
+
tailBytes: 2 * 1024 * 1024,
|
|
242
|
+
// OFF by default, and NOT SUPPORTED AGAINST BITCOIN CORE.
|
|
243
|
+
//
|
|
244
|
+
// logparse.js targets an experimental node's log grammar: its rules key on [dlc], [dl],
|
|
245
|
+
// [dial], [utxo_live] and [config] tags, and its timestamp rule wants
|
|
246
|
+
// "YYYY-MM-DD HH:MM:SS.mmm ", not the "2026-09-13T01:30:00Z" Core writes. Measured
|
|
247
|
+
// 2026-09-13 against real Core debug.log lines: every line comes back as an unstructured
|
|
248
|
+
// `raw` event with NO fields extracted, and its timestamp falls back to the time of reading
|
|
249
|
+
// rather than the time in the line -- so turning this on against Core adds nothing and
|
|
250
|
+
// misdates the event feed. Both fixtures in test/fixtures/ are experimental-format; no Core
|
|
251
|
+
// log has ever been tested against this parser (test/log-core-unsupported.test.js pins that).
|
|
252
|
+
//
|
|
253
|
+
// Off since 2026-09-09 for an independent reason that still holds: tailing a file the node
|
|
254
|
+
// rewrites between releases is a grammar dependency that has already cost this project two
|
|
255
|
+
// silent-outage incidents, and every panel that needed it was removed rather than left
|
|
256
|
+
// showing dashes. Nothing in the UI depends on the log.
|
|
257
|
+
//
|
|
258
|
+
// The measurements that motivated the option are from the experimental node and are kept for
|
|
259
|
+
// the reasoning, not as claims about Core: on the build deployed there, RPC answered
|
|
260
|
+
// getnettotals 0/0 and getpeerinfo [] while getconnectioncount said 16, so "RPC only" meant
|
|
261
|
+
// "no bandwidth and no peer names at all" -- and both builds reported the same non-Core
|
|
262
|
+
// subversion string, so RPC could not tell you which one you had (MEASUREMENTS 3, 4, 11, 26).
|
|
263
|
+
enabled: false,
|
|
264
|
+
// How long a tailed file may go without a single new byte before the monitor
|
|
265
|
+
// says so. Default 30 min: measured 2026-09-08, the synced production node's own
|
|
266
|
+
// log went 1,182 s (~20 min) between lines at its quietest across 1,840 lines,
|
|
267
|
+
// so anything under ~20 min flags a healthy idle node as broken. A per-node
|
|
268
|
+
// `logStaleMs` tightens it where the node is known to be chatty.
|
|
269
|
+
staleMs: 1800000,
|
|
270
|
+
// How often that check runs. It is a stat() plus arithmetic, no RPC, so it can
|
|
271
|
+
// be far more frequent than anything that touches the node's single lane.
|
|
272
|
+
healthMs: 30000,
|
|
273
|
+
},
|
|
274
|
+
// The Markets tab (server/collect/markets.js): public exchange APIs over HTTPS -- the one
|
|
275
|
+
// outbound connection that is not the node. Polled only while someone has the tab open, and
|
|
276
|
+
// parked idleAfterMs after the last request. BLOCKYARD_MARKETS=0 turns it off.
|
|
277
|
+
markets: {
|
|
278
|
+
enabled: true,
|
|
279
|
+
tickerMs: 15000,
|
|
280
|
+
candleMs: 300000,
|
|
281
|
+
bookMs: 30000, // order books, for the depth chart
|
|
282
|
+
idleAfterMs: 600000,
|
|
283
|
+
timeoutMs: 8000,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
function isPlainObject(v) {
|
|
288
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function deepMerge(base, extra) {
|
|
292
|
+
if (extra === undefined || extra === null) return base;
|
|
293
|
+
if (Array.isArray(base) || Array.isArray(extra)) return extra;
|
|
294
|
+
if (!isPlainObject(base) || !isPlainObject(extra)) return extra;
|
|
295
|
+
const out = { ...base };
|
|
296
|
+
for (const k of Object.keys(extra)) out[k] = deepMerge(base[k], extra[k]);
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Bind accepts one address, a comma list, or a JSON array -- because "LAN and the
|
|
301
|
+
// tunnel, but not the container bridges" cannot be said with a single socket.
|
|
302
|
+
function hostList(v) {
|
|
303
|
+
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
|
|
304
|
+
return String(v).split(',').map((x) => x.trim()).filter(Boolean);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read one env var and put it in the shape the config expects.
|
|
309
|
+
*
|
|
310
|
+
* This function used to handle only Number and Boolean and silently return the raw
|
|
311
|
+
* string for every *function* cast -- and half the entries in the table below pass a
|
|
312
|
+
* function. Measured consequence, found by test/cidr.test.js on 2026-09-09:
|
|
313
|
+
*
|
|
314
|
+
* BLOCKYARD_ALLOW_CIDRS=203.0.113.0/24 -> the string "203.0.113.0/24", not a list.
|
|
315
|
+
* The gate then iterated it CHARACTER by character, matched nothing, and refused
|
|
316
|
+
* every address -- so the documented way to restrict the monitor to a LAN was a
|
|
317
|
+
* deny-all firewall that also locked out the operator.
|
|
318
|
+
*
|
|
319
|
+
* BLOCKYARD_ACTIONS=broadcast -> a string, and `allow.includes(name)` is a substring
|
|
320
|
+
* test on a string: permissions decided by substring matching instead of set
|
|
321
|
+
* membership. It happened not to grant anything today only because the action names
|
|
322
|
+
* do not contain each other.
|
|
323
|
+
*
|
|
324
|
+
* The host list survived by luck: validate() re-splits a string there.
|
|
325
|
+
*/
|
|
326
|
+
function env(name, cast) {
|
|
327
|
+
const raw = process.env[name];
|
|
328
|
+
if (raw === undefined || raw === '') return undefined;
|
|
329
|
+
if (cast === Number) return Number(raw);
|
|
330
|
+
if (cast === Boolean) return /^(1|true|yes|on)$/i.test(raw);
|
|
331
|
+
if (typeof cast === 'function') return cast(raw);
|
|
332
|
+
return raw;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Where the machine-specific file lives -- and how a hermetic run opts out of it.
|
|
337
|
+
*
|
|
338
|
+
* Tests and the dev fake-node boot used to inherit whatever `config/local.json`
|
|
339
|
+
* happened to say on this box. That is how a *deployment* decision (bind this box's
|
|
340
|
+
* LAN + tailnet addresses) silently broke 54 smoke assertions: the script pinned its
|
|
341
|
+
* data dir, ports and admin password, curled `127.0.0.1` -- and the server under test
|
|
342
|
+
* was not listening there. A machine config must not leak into a hermetic run, so
|
|
343
|
+
* give it a way to say "no file".
|
|
344
|
+
*/
|
|
345
|
+
function defaultConfigFile() {
|
|
346
|
+
const e = process.env.BLOCKYARD_CONFIG;
|
|
347
|
+
if (e === undefined || e === '') return path.join(ROOT, 'config', 'local.json');
|
|
348
|
+
// A literal device path (e.g. /dev/null) is not a config file either; treat the
|
|
349
|
+
// sentinel and a non-regular file the same way rather than throwing on parse.
|
|
350
|
+
if (/^(none|off|no|-)$/i.test(e)) return null;
|
|
351
|
+
return e;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// `ifaces` is injectable so tests are hermetic. Without it, validating a bind
|
|
355
|
+
// address against os.networkInterfaces() meant any test naming a bind address
|
|
356
|
+
// could only pass by naming THIS machine's real address -- fragile across lease
|
|
357
|
+
// changes, and a standing reason to commit a host address into the repository.
|
|
358
|
+
/**
|
|
359
|
+
* `ifaces` and `now` are injectable so the checks below are testable without
|
|
360
|
+
* touching this machine's real interfaces or waiting for a certificate to expire.
|
|
361
|
+
* Both have production defaults; neither is read from anywhere but the caller.
|
|
362
|
+
*/
|
|
363
|
+
export function loadConfig({ configFile = defaultConfigFile(), ifaces = null, now = Date.now() } = {}) {
|
|
364
|
+
let fileCfg = {};
|
|
365
|
+
if (configFile && fs.existsSync(configFile) && fs.statSync(configFile).isFile()) {
|
|
366
|
+
try {
|
|
367
|
+
fileCfg = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
368
|
+
} catch (err) {
|
|
369
|
+
throw new Error(`config: cannot parse ${configFile}: ${err.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
let cfg = deepMerge(structuredClone(DEFAULTS), fileCfg);
|
|
373
|
+
|
|
374
|
+
const e = {
|
|
375
|
+
'BLOCKYARD_HOST': ['server.host', hostList],
|
|
376
|
+
'BLOCKYARD_PORT': ['server.port', Number],
|
|
377
|
+
'BLOCKYARD_BIND': ['server.host', hostList],
|
|
378
|
+
'BLOCKYARD_ALLOW_CIDRS': ['server.allowCidrs', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)],
|
|
379
|
+
'BLOCKYARD_TRUST_PROXY': ['server.trustProxy', Boolean],
|
|
380
|
+
'BLOCKYARD_NODE_URL': ['__nodeUrl', String],
|
|
381
|
+
'BLOCKYARD_DATADIR': ['__datadir', String],
|
|
382
|
+
'BLOCKYARD_LOGFILE': ['__logfile', String],
|
|
383
|
+
'BLOCKYARD_COOKIE': ['__cookie', String],
|
|
384
|
+
'BLOCKYARD_UNIT': ['__unit', String],
|
|
385
|
+
'BLOCKYARD_NODE_LABEL': ['__label', String],
|
|
386
|
+
'BLOCKYARD_RPC_TIMEOUT': ['rpc.timeoutMs', Number],
|
|
387
|
+
'BLOCKYARD_RPC_MIN_INTERVAL': ['rpc.minIntervalMs', Number],
|
|
388
|
+
'BLOCKYARD_RPC_STALE_DROP': ['rpc.staleDropMs', Number],
|
|
389
|
+
'BLOCKYARD_DATA': ['store.dir', String],
|
|
390
|
+
'BLOCKYARD_AUTH': ['auth.enabled', Boolean],
|
|
391
|
+
'BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH': ['actions.allowWritesWithoutAuth', Boolean],
|
|
392
|
+
// Run on RPC alone: 0 turns the log tail off for every node. Measured why is
|
|
393
|
+
// in server/collect/monitor.js and MEASUREMENTS 3/4 -- bandwidth and per-peer
|
|
394
|
+
// bytes work on builds that publish them and do not on the deployed one.
|
|
395
|
+
'BLOCKYARD_LOG_SOURCE': ['log.enabled', Boolean],
|
|
396
|
+
'BLOCKYARD_MARKETS': ['markets.enabled', Boolean],
|
|
397
|
+
'BLOCKYARD_SECURE_COOKIE': ['auth.secureCookie', Boolean],
|
|
398
|
+
'BLOCKYARD_TLS_CERT': ['server.tls.cert', String],
|
|
399
|
+
'BLOCKYARD_TLS_KEY': ['server.tls.key', String],
|
|
400
|
+
'BLOCKYARD_ACTIONS': ['actions.allow', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)],
|
|
401
|
+
'BLOCKYARD_ENABLE_ACTIONS': ['actions.enabled', Boolean],
|
|
402
|
+
'BLOCKYARD_LOG_LEVEL': ['log.level', String],
|
|
403
|
+
'BLOCKYARD_RETENTION_HOURS': ['store.retentionHours', Number],
|
|
404
|
+
};
|
|
405
|
+
// Sentinel keys gathered below (they address nodes[0], a fixed path would not).
|
|
406
|
+
const sentinels = {};
|
|
407
|
+
for (const [name, [p, cast]] of Object.entries(e)) {
|
|
408
|
+
const v = env(name, cast);
|
|
409
|
+
if (v === undefined) continue;
|
|
410
|
+
if (p.startsWith('__')) { sentinels[p.slice(2)] = v; continue; }
|
|
411
|
+
setPath(cfg, p, v);
|
|
412
|
+
}
|
|
413
|
+
if (sentinels.nodeUrl) {
|
|
414
|
+
cfg.nodes[0].rpcUrl = sentinels.nodeUrl;
|
|
415
|
+
cfg.nodes[0].__urlOverridden = true;
|
|
416
|
+
}
|
|
417
|
+
if (sentinels.datadir) {
|
|
418
|
+
cfg.nodes[0].datadir = sentinels.datadir;
|
|
419
|
+
cfg.nodes[0].cookieFile = null;
|
|
420
|
+
}
|
|
421
|
+
if (sentinels.logfile) cfg.nodes[0].logFile = sentinels.logfile;
|
|
422
|
+
if (sentinels.cookie) cfg.nodes[0].cookieFile = sentinels.cookie;
|
|
423
|
+
if (sentinels.unit) cfg.nodes[0].systemdUnit = sentinels.unit;
|
|
424
|
+
// A node is named by whoever knows its name: an explicit label -- from the environment or from
|
|
425
|
+
// the file -- is the operator speaking, and always wins. Failing that, a node whose URL was
|
|
426
|
+
// overridden is named by the endpoint it actually answers on, which cannot be wrong. Keeping
|
|
427
|
+
// the built-in label there would state something false about a node nobody named; the
|
|
428
|
+
// fallback states only what was measured. Nodes nobody redirected keep their built-in name.
|
|
429
|
+
const fileNode0 = Array.isArray(fileCfg.nodes) ? fileCfg.nodes[0] : null;
|
|
430
|
+
const fileLabel = fileNode0 && fileNode0.label;
|
|
431
|
+
// The address this node was already configured for, before the environment spoke. An override
|
|
432
|
+
// that names this same address has redirected nothing, so the built-in name still describes the
|
|
433
|
+
// node being polled and must stand.
|
|
434
|
+
const baseUrl = (fileNode0 && fileNode0.rpcUrl) || DEFAULTS.nodes[0].rpcUrl;
|
|
435
|
+
const movedNode = !!sentinels.nodeUrl && sentinels.nodeUrl !== baseUrl;
|
|
436
|
+
// Guarded, because `nodes: []` is refused by validate() below with a sentence that names the
|
|
437
|
+
// mistake, and reaching into nodes[0] before then would replace that sentence with a TypeError.
|
|
438
|
+
if (cfg.nodes[0]) {
|
|
439
|
+
if (sentinels.label) cfg.nodes[0].label = sentinels.label;
|
|
440
|
+
else if (movedNode && !fileLabel) {
|
|
441
|
+
let host = cfg.nodes[0].rpcUrl;
|
|
442
|
+
try { host = new URL(cfg.nodes[0].rpcUrl).host; } catch { /* validate() reports a bad URL */ }
|
|
443
|
+
cfg.nodes[0].label = `node @ ${host}`;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (env('BLOCKYARD_FAKE_NODE', Boolean)) cfg.__fakeNode = true;
|
|
448
|
+
|
|
449
|
+
cfg.store.dir = cfg.store.dir || path.join(ROOT, 'data');
|
|
450
|
+
cfg.auth.dataDir = cfg.auth.dataDir || cfg.store.dir;
|
|
451
|
+
cfg.nodes.forEach((n, i) => { n.id = n.id || `node-${i}`; });
|
|
452
|
+
|
|
453
|
+
validate(cfg, ifaces, now);
|
|
454
|
+
cfg.__defaultsDoc = HERE_DOC;
|
|
455
|
+
// WHICH FILE THIS CAME FROM. The path was an argument, used and then forgotten, so nothing
|
|
456
|
+
// downstream could say where the settings live -- and a UI that offers to save a connection must
|
|
457
|
+
// name the file it would write rather than guess at one. `null` is a real answer: a hermetic run
|
|
458
|
+
// (BLOCKYARD_CONFIG=none) has no file, and a save must be refused rather than invent a path.
|
|
459
|
+
cfg.__configFile = configFile ?? null;
|
|
460
|
+
return cfg;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function setPath(obj, dotted, value) {
|
|
464
|
+
const parts = dotted.split('.');
|
|
465
|
+
let cur = obj;
|
|
466
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
467
|
+
if (!isPlainObject(cur[parts[i]])) cur[parts[i]] = {};
|
|
468
|
+
cur = cur[parts[i]];
|
|
469
|
+
}
|
|
470
|
+
cur[parts[parts.length - 1]] = value;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const problems = [];
|
|
474
|
+
export function configProblems() { return problems; }
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* TLS, checked at load, because every failure mode here is worse later.
|
|
478
|
+
*
|
|
479
|
+
* Half a TLS configuration is the important one. `cert` without `key` would
|
|
480
|
+
* otherwise be silently ignored, which means an operator who set one line believed
|
|
481
|
+
* the monitor was serving HTTPS while it was serving plaintext on the same port.
|
|
482
|
+
* So a partial pair is fatal, not warned.
|
|
483
|
+
*
|
|
484
|
+
* The certificate is parsed with crypto.X509Certificate (a builtin, so no
|
|
485
|
+
* dependency for a fact this important) and an expired cert is fatal too: a browser
|
|
486
|
+
* refusing the connection is not information the dashboard can surface, because the
|
|
487
|
+
* dashboard is on the other side of that refusal.
|
|
488
|
+
*/
|
|
489
|
+
/**
|
|
490
|
+
* TLS, checked at load. `now` is injectable so the expiry branch is a tested
|
|
491
|
+
* branch rather than a comment about a future date.
|
|
492
|
+
*/
|
|
493
|
+
function validateTls(cfg, now = Date.now()) {
|
|
494
|
+
const tls = cfg.server.tls ?? {};
|
|
495
|
+
cfg.server.tls = tls;
|
|
496
|
+
cfg.tls = Boolean(tls.cert || tls.key);
|
|
497
|
+
if (!cfg.tls) return;
|
|
498
|
+
if (!tls.cert || !tls.key) {
|
|
499
|
+
problems.push(`server.tls needs BOTH cert and key (got ${tls.cert ? 'cert only' : 'key only'}); a half-configured TLS would fall back to plaintext on a port you believe is HTTPS`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
for (const [what, file] of [['cert', tls.cert], ['key', tls.key]]) {
|
|
503
|
+
try {
|
|
504
|
+
fs.readFileSync(file);
|
|
505
|
+
} catch (err) {
|
|
506
|
+
problems.push(`server.tls.${what} cannot be read (${file}: ${err.code ?? err.message})`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (problems.length) return;
|
|
510
|
+
try {
|
|
511
|
+
const x = new crypto.X509Certificate(fs.readFileSync(tls.cert, 'utf8'));
|
|
512
|
+
tls.fingerprint = x.fingerprint256;
|
|
513
|
+
tls.notAfter = Date.parse(x.validTo);
|
|
514
|
+
tls.selfSigned = x.issuer === x.subject;
|
|
515
|
+
if (!Number.isFinite(tls.notAfter)) problems.push('server.tls.cert has no parseable validity window');
|
|
516
|
+
else if (tls.notAfter <= now) {
|
|
517
|
+
problems.push(`server.tls.cert expired ${new Date(tls.notAfter).toISOString()}; the browser will refuse the connection, and the dashboard cannot tell you so from behind that refusal`);
|
|
518
|
+
} else if (tls.notAfter - now < 14 * 86_400_000) {
|
|
519
|
+
cfg.__tlsExpiring = `certificate expires ${new Date(tls.notAfter).toISOString()}`;
|
|
520
|
+
}
|
|
521
|
+
} catch (err) {
|
|
522
|
+
problems.push(`server.tls.cert is not a parseable X.509 certificate: ${err.message}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
function validate(cfg, ifaces = null, now = Date.now()) {
|
|
528
|
+
problems.length = 0;
|
|
529
|
+
if (!Array.isArray(cfg.nodes) || cfg.nodes.length === 0) problems.push('nodes must be non-empty');
|
|
530
|
+
if (!Number.isInteger(cfg.server.port) || cfg.server.port < 1 || cfg.server.port > 65535) problems.push('server.port invalid');
|
|
531
|
+
// `hosts` is the truth: one address, a comma list, or an array all normalise here.
|
|
532
|
+
// `host` stays populated with the first entry for anything that still reads it.
|
|
533
|
+
cfg.server.hosts = hostList(cfg.server.hosts ?? cfg.server.host ?? '0.0.0.0');
|
|
534
|
+
cfg.server.host = cfg.server.hosts[0];
|
|
535
|
+
if (!cfg.server.hosts.length) problems.push('server.hosts is empty; nothing would be served');
|
|
536
|
+
// A hostname here binds whatever DNS says at boot, and fails at listen() with a
|
|
537
|
+
// message about names -- or, after a reboot with changed DNS, at the worst moment.
|
|
538
|
+
// Refuse it at load and name the alternative.
|
|
539
|
+
for (const h of cfg.server.hosts) {
|
|
540
|
+
if (!isBindableHost(h)) problems.push(`server.hosts entry "${h}" is not an address literal; use an IPv4/IPv6 address, 0.0.0.0, or localhost`);
|
|
541
|
+
}
|
|
542
|
+
// Warn at boot -- not fatal -- about addresses this machine cannot take right now
|
|
543
|
+
// (a tunnel interface that comes up after us is the common case).
|
|
544
|
+
const plan = planBinds(cfg.server.hosts, ifaces ?? undefined);
|
|
545
|
+
if (plan.noneUsable) problems.push(`none of the configured bind addresses (${plan.list.join(', ')}) exist on this machine; refusing to start with nothing to serve`);
|
|
546
|
+
else if (plan.missing.length) cfg.server.hostsMissing = plan.missing;
|
|
547
|
+
if (cfg.rpc.maxInFlight < 1) problems.push('rpc.maxInFlight must be >= 1');
|
|
548
|
+
if (cfg.poll.fastMs < 1000) problems.push('poll.fastMs below 1s risks hammering a single-threaded RPC server');
|
|
549
|
+
// An allowlist entry that does not parse is an inert rule, and an inert rule in an
|
|
550
|
+
// allowlist is a hole that looks like a policy. The matcher also refuses to let one
|
|
551
|
+
// admit anything (server/netinfo.js ipDecision), but the operator has to hear about
|
|
552
|
+
// it at boot rather than notice when the gate disagrees with the file.
|
|
553
|
+
for (const c of cfg.server.allowCidrs ?? []) {
|
|
554
|
+
const parsed = parseCidr(c);
|
|
555
|
+
if (!parsed.ok) problems.push(`server.allowCidrs entry "${c}" is unusable: ${parsed.reason}`);
|
|
556
|
+
}
|
|
557
|
+
validateTls(cfg, now);
|
|
558
|
+
// "Open to everyone" plus "node writes enabled" is the one combination where the
|
|
559
|
+
// role gate is vacuous: with no accounts there is no role to check, so every
|
|
560
|
+
// action in actions.allow is callable by whoever can reach the port. Refuse to
|
|
561
|
+
// boot on that configuration rather than trusting that the operator meant it; the
|
|
562
|
+
// override exists precisely so it has to be chosen twice.
|
|
563
|
+
if (cfg.actions.enabled && !cfg.auth.enabled && !cfg.actions.allowWritesWithoutAuth) {
|
|
564
|
+
problems.push('node actions are enabled while accounts are OFF, which would let any address that can reach the port call them (there is no role to check). Either set BLOCKYARD_AUTH=1, or set BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1 deliberately alongside BLOCKYARD_ACTIONS.');
|
|
565
|
+
}
|
|
566
|
+
if (!cfg.auth.enabled) {
|
|
567
|
+
// Not a problem, a fact the operator should see once at boot.
|
|
568
|
+
cfg.__openAccess = true;
|
|
569
|
+
}
|
|
570
|
+
if (!(Number.isInteger(cfg.store.blockMapCap) && cfg.store.blockMapCap >= 100)) {
|
|
571
|
+
problems.push('store.blockMapCap must be an integer >= 100; a block map that holds nothing renders "no blocks" as if it were a fact');
|
|
572
|
+
}
|
|
573
|
+
if (!(Number.isInteger(cfg.store.auditMaxBytes) && cfg.store.auditMaxBytes >= 64 * 1024)) {
|
|
574
|
+
problems.push('store.auditMaxBytes must be >= 65536; below that the audit rotates on every write');
|
|
575
|
+
}
|
|
576
|
+
for (const n of cfg.nodes) {
|
|
577
|
+
if (!n.rpcUrl || !/^https?:\/\//.test(n.rpcUrl)) problems.push(`node ${n.id}: rpcUrl must be http(s)://host:port`);
|
|
578
|
+
// AUTHENTICATION CAN COME FROM EITHER PLACE. Cookie auth needs a datadir (or an explicit
|
|
579
|
+
// cookieFile) to read <datadir>/<chain>/.cookie -- but a node that authenticates with rpcauth
|
|
580
|
+
// has no cookie to read, and rpcUser/rpcPassword is the way in. resolveCookie() has always
|
|
581
|
+
// supported that (it falls through to the configured user/password); this validator did not,
|
|
582
|
+
// so a user/password node was refused at boot with "need datadir or cookieFile". Found
|
|
583
|
+
// 2026-09-13. BlockYard runs on the node's machine (the explorer needs its block files), so
|
|
584
|
+
// this is for a node that uses rpcauth, not a node elsewhere.
|
|
585
|
+
const hasCookiePath = !!(n.datadir || n.cookieFile);
|
|
586
|
+
const hasUserPass = !!(n.rpcUser && n.rpcPassword);
|
|
587
|
+
if (!hasCookiePath && !hasUserPass) {
|
|
588
|
+
problems.push(`node ${n.id}: needs either datadir/cookieFile (cookie auth, same machine) or rpcUser + rpcPassword (a node authenticating with rpcauth)`);
|
|
589
|
+
}
|
|
590
|
+
if (n.rpcUser && !n.rpcPassword) problems.push(`node ${n.id}: rpcUser is set but rpcPassword is empty`);
|
|
591
|
+
}
|
|
592
|
+
if (problems.length) {
|
|
593
|
+
throw new Error(`Invalid configuration:\n - ${problems.join('\n - ')}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Cookie resolution. <datadir>/<chain>/.cookie is the real layout; the file is
|
|
598
|
+
// regenerated on every boot and deleted on shutdown, so a cached cookie is a
|
|
599
|
+
// liability and callers re-resolve on 401 rather than trusting a stale value.
|
|
600
|
+
export function resolveCookie(node) {
|
|
601
|
+
const candidates = [];
|
|
602
|
+
if (node.cookieFile) candidates.push(node.cookieFile);
|
|
603
|
+
if (node.datadir) {
|
|
604
|
+
if (node.chainHint) candidates.push(path.join(node.datadir, node.chainHint, '.cookie'));
|
|
605
|
+
candidates.push(path.join(node.datadir, '.cookie'));
|
|
606
|
+
try {
|
|
607
|
+
for (const ent of fs.readdirSync(node.datadir, { withFileTypes: true })) {
|
|
608
|
+
if (ent.isDirectory()) candidates.push(path.join(node.datadir, ent.name, '.cookie'));
|
|
609
|
+
}
|
|
610
|
+
} catch { /* datadir not readable from here; the explicit paths still stand */ }
|
|
611
|
+
}
|
|
612
|
+
for (const c of candidates) {
|
|
613
|
+
try {
|
|
614
|
+
const raw = fs.readFileSync(c, 'utf8').trim();
|
|
615
|
+
if (raw.includes(':')) return { user: raw.split(':')[0], password: raw.slice(raw.indexOf(':') + 1), source: c };
|
|
616
|
+
} catch { /* try next */ }
|
|
617
|
+
}
|
|
618
|
+
if (node.rpcUser) return { user: node.rpcUser, password: node.rpcPassword || '', source: 'config' };
|
|
619
|
+
return null;
|
|
620
|
+
}
|