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/main.js
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
// Entry point: build the app, boot the monitors, serve, and shut down cleanly.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { loadConfig, ROOT } from './config.js';
|
|
6
|
+
import { History } from './store/history.js';
|
|
7
|
+
import { AuditLog } from './store/audit.js';
|
|
8
|
+
import { UserStore, randomPassword } from './auth/users.js';
|
|
9
|
+
import { SessionStore, RateLimiter, LoginGuard } from './auth/sessions.js';
|
|
10
|
+
import { StreamHub } from './http/sse.js';
|
|
11
|
+
import { createAppServer } from './http/server.js';
|
|
12
|
+
import { computeBuildId } from './http/static.js';
|
|
13
|
+
import { NodeMonitor } from './collect/monitor.js';
|
|
14
|
+
import { localAddresses, bindProblemMessage, planBinds } from './netinfo.js';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
// ONE PLACE, NOT TWO. This was a literal here AND a "version" field in package.json, and on
|
|
18
|
+
// 2026-09-13 they had drifted: this said 0.0.9 while CHANGELOG.md released [0.9.0]. Harmless until
|
|
19
|
+
// something compares versions -- and the auto-update design (docs/AUTO-UPDATE.md) compares exactly
|
|
20
|
+
// this field to decide whether a release is newer, so the drift would have made it answer wrongly.
|
|
21
|
+
// package.json is the authority; a test asserts the two agree.
|
|
22
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
|
23
|
+
|
|
24
|
+
export async function boot({ configFile, log: logOverride = null } = {}) {
|
|
25
|
+
const cfg = loadConfig({ configFile });
|
|
26
|
+
await fsp.mkdir(cfg.store.dir, { recursive: true });
|
|
27
|
+
await fsp.mkdir(cfg.auth.dataDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
const app = {
|
|
30
|
+
version: VERSION,
|
|
31
|
+
cfg,
|
|
32
|
+
// the file the settings were read from, or null for a run that was given none
|
|
33
|
+
configFile: cfg.__configFile ?? null,
|
|
34
|
+
// DISPLAY SETTINGS live on the server (operator, 2026-09-13: "This is a server app. Should
|
|
35
|
+
// store things on a server"). They were per-browser localStorage, which meant a kiosk screen
|
|
36
|
+
// and a desk looking at the same monitor kept different answers and neither could be read back.
|
|
37
|
+
// Beside local.json rather than in data/: it is configuration a person chose, not runtime state.
|
|
38
|
+
//
|
|
39
|
+
// DERIVED FROM THE CONFIG FILE, not pinned to ROOT. A hardcoded repo path meant any test that
|
|
40
|
+
// POSTed settings wrote the REAL config/blockyard.json of the working copy -- a test run
|
|
41
|
+
// clobbering a deployment's own preferences. Following configFile puts it in the temp dir for a
|
|
42
|
+
// hermetic boot and leaves it exactly where it already is for this one (the unit sets no
|
|
43
|
+
// BLOCKYARD_CONFIG, so configFile is ROOT/config/local.json and the dirname is unchanged).
|
|
44
|
+
// env is deliberately NOT the lever: helpers/http.js touches no process.env, because Node runs
|
|
45
|
+
// a file's tests concurrently and a mutated var leaks into a sibling's boot.
|
|
46
|
+
settingsFile: path.join(
|
|
47
|
+
cfg.__configFile ? path.dirname(cfg.__configFile) : path.join(ROOT, 'config'),
|
|
48
|
+
'blockyard.json',
|
|
49
|
+
),
|
|
50
|
+
startedAt: Date.now(),
|
|
51
|
+
publicDir: path.join(ROOT, 'public'),
|
|
52
|
+
monitors: new Map(),
|
|
53
|
+
stateSeq: 0,
|
|
54
|
+
rssStart: process.memoryUsage().rss,
|
|
55
|
+
cpuStart: process.cpuUsage(),
|
|
56
|
+
selfRing: [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
app.log = logOverride ?? makeLogger(cfg);
|
|
60
|
+
// `logOverride` exists so a test can capture what the boot said. The alternative —
|
|
61
|
+
// replacing process.stdout.write for the duration — raced the test runner's own TAP
|
|
62
|
+
// writer and swallowed the results of other tests in the same file, which is a
|
|
63
|
+
// worse outcome than the silence it was trying to observe.
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------- TLS
|
|
66
|
+
// Decided here, before any listener exists, and the cookie follows it: a Secure
|
|
67
|
+
// cookie on an HTTP listener is a cookie the browser will not send, which reads
|
|
68
|
+
// as "login keeps failing" -- so the two settings must not be independently set.
|
|
69
|
+
app.tls = Boolean(cfg.server.tls?.cert && cfg.server.tls?.key);
|
|
70
|
+
if (app.tls) {
|
|
71
|
+
app.tlsOptions = {
|
|
72
|
+
cert: fs.readFileSync(cfg.server.tls.cert),
|
|
73
|
+
key: fs.readFileSync(cfg.server.tls.key),
|
|
74
|
+
};
|
|
75
|
+
if (!cfg.auth.secureCookie) {
|
|
76
|
+
cfg.auth.secureCookie = true;
|
|
77
|
+
app.log({ level: 'info', msg: 'TLS is on, so the session cookie is now Secure (a Secure cookie over plain HTTP is never sent, which looks like a login that will not stick)' });
|
|
78
|
+
}
|
|
79
|
+
app.log({ level: 'warn', msg: `TLS on (fingerprint ${String(cfg.server.tls.fingerprint).slice(0, 17)}…${cfg.server.tls.selfSigned ? ', self-signed: expect a browser warning the first time per address' : ''})${cfg.__tlsExpiring ? `; WARNING ${cfg.__tlsExpiring}` : ''}` });
|
|
80
|
+
} else if (cfg.auth.enabled) {
|
|
81
|
+
app.log({ level: 'warn', msg: 'serving HTTP, not HTTPS: the session cookie and every RPC reply cross the LAN in the clear. Either put a TLS terminator in front (then BLOCKYARD_SECURE_COOKIE=1), name server.tls.cert/key, or bind 127.0.0.1 and use an SSH tunnel -- see README, "TLS, or the lack of it".' });
|
|
82
|
+
}
|
|
83
|
+
app.scheme = app.tls ? 'https' : 'http';
|
|
84
|
+
|
|
85
|
+
// The build id the pages are stamped with and /api/build reports. The browser
|
|
86
|
+
// compares the two and says "this tab is running an older build" in words;
|
|
87
|
+
// without it, a stale tab is indistinguishable from a fix that did not land.
|
|
88
|
+
app.buildId = async () => computeBuildId(app.publicDir, VERSION);
|
|
89
|
+
app.build = await app.buildId();
|
|
90
|
+
|
|
91
|
+
app.history = new History(cfg.store.dir, cfg.store, { log: app.log });
|
|
92
|
+
const loaded = await app.history.load();
|
|
93
|
+
app.log({ level: 'info', msg: `history: ${loaded.loaded ? `restored ${loaded.points} points from ${new Date(loaded.savedAt).toISOString()}` : `starting empty (${loaded.reason})`}` });
|
|
94
|
+
app.history.startAutosave();
|
|
95
|
+
|
|
96
|
+
app.users = new UserStore(path.join(cfg.auth.dataDir, 'users.json'), cfg.auth);
|
|
97
|
+
const userLoad = await app.users.load();
|
|
98
|
+
app.sessions = new SessionStore(path.join(cfg.auth.dataDir, 'sessions.json'), cfg.auth);
|
|
99
|
+
await app.sessions.load();
|
|
100
|
+
app.guard = new LoginGuard(cfg.auth);
|
|
101
|
+
app.limiter = new RateLimiter({ capacity: 120, perSec: 40 });
|
|
102
|
+
// A second, separate bucket for /api/login only. LoginGuard answers "this
|
|
103
|
+
// username keeps failing"; the per-user token bucket cannot cover login because
|
|
104
|
+
// it is keyed on the authenticated user and login has none yet. What was
|
|
105
|
+
// unmitigated was the slow distributed grind: N addresses at 1 attempt each per
|
|
106
|
+
// second, under every lockout threshold. Cost of a login attempt is a scrypt KDF
|
|
107
|
+
// (~50 ms, 16 MB) on the request thread, so this is also the only thing keeping
|
|
108
|
+
// a cheap flood from becoming a CPU denial of service on the monitor.
|
|
109
|
+
app.loginLimiter = new RateLimiter({ capacity: 10, perSec: 0.5 });
|
|
110
|
+
app.hub = new StreamHub({ log: app.log });
|
|
111
|
+
|
|
112
|
+
// First run has to produce a credential somehow. Env-provided wins; otherwise
|
|
113
|
+
// one is generated, used once and never persisted in recoverable form.
|
|
114
|
+
let bootstrap = null;
|
|
115
|
+
if (cfg.auth.enabled && app.users.count === 0) {
|
|
116
|
+
const pw = process.env.BLOCKYARD_ADMIN_PASSWORD || randomPassword(20);
|
|
117
|
+
const created = await app.users.createUser('admin', pw, { role: 'admin' });
|
|
118
|
+
bootstrap = { username: created.username, password: pw, generated: !process.env.BLOCKYARD_ADMIN_PASSWORD };
|
|
119
|
+
app.log({ level: 'warn', msg: `created the first admin account (${created.username}) -- ${bootstrap.generated ? 'generated password below is shown once' : 'password from BLOCKYARD_ADMIN_PASSWORD'}` });
|
|
120
|
+
}
|
|
121
|
+
app.bootstrap = bootstrap;
|
|
122
|
+
|
|
123
|
+
// Open access is a posture, so it is announced rather than left implicit. The line
|
|
124
|
+
// names the addresses that are now readable by anyone and the one switch that
|
|
125
|
+
// closes it, because "anyone on the LAN can read the node" must never be something
|
|
126
|
+
// an operator discovers from a screenshot.
|
|
127
|
+
if (!cfg.auth.enabled) {
|
|
128
|
+
const where = (cfg.server.hosts ?? [cfg.server.host]).join(', ') || '(wildcard)';
|
|
129
|
+
app.log({
|
|
130
|
+
level: 'warn',
|
|
131
|
+
msg: `NO SIGN-IN (auth.enabled=false, the default): anyone who can reach ${where}:${cfg.server.port} reads this monitor — charts, the event feed, peer and mempool detail, and the read-only RPC console — as role "viewer". Not open to them: user administration, the audit trail, password changes, and node writes (set BLOCKYARD_AUTH=1 for accounts, roles, sessions and CSRF).`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (cfg.__fakeNode) {
|
|
136
|
+
const { startFakeNode } = await import('../scripts/fake-node.js');
|
|
137
|
+
const logFile = path.join(cfg.store.dir, 'fake-node.log');
|
|
138
|
+
let fake;
|
|
139
|
+
try {
|
|
140
|
+
fake = await startFakeNode({
|
|
141
|
+
port: Number(process.env.FAKE_PORT || 18331),
|
|
142
|
+
logFile,
|
|
143
|
+
ibd: process.env.FAKE_IBD !== '0',
|
|
144
|
+
catchupBlocksPerSec: Number(process.env.FAKE_RATE || 9),
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
throw new Error(`dev mode needs a local fake node: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
cfg.nodes[0].rpcUrl = fake.url;
|
|
150
|
+
cfg.nodes[0].cookieFile = null;
|
|
151
|
+
cfg.nodes[0].rpcUser = 'fake';
|
|
152
|
+
cfg.nodes[0].rpcPassword = 'fake';
|
|
153
|
+
cfg.nodes[0].datadir = cfg.store.dir;
|
|
154
|
+
cfg.nodes[0].logFile = logFile;
|
|
155
|
+
cfg.nodes[0].label = 'Fake node (IBD simulation)';
|
|
156
|
+
// Dev and smoke runs are hermetic: keep ONLY the fake. Leaving the real
|
|
157
|
+
// default nodes in place would have `npm run dev` and scripts/smoke.sh
|
|
158
|
+
// polling the operator's actual bench node -- a benchmark run disturbed by a
|
|
159
|
+
// test suite is a nasty class of interference.
|
|
160
|
+
cfg.nodes = [cfg.nodes[0]];
|
|
161
|
+
app.fakeNode = fake;
|
|
162
|
+
app.log({ level: 'warn', msg: `fake node running at ${fake.url}, logging to ${logFile}` });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// RPC-only mode. Blanked here rather than in config.js so the config object
|
|
166
|
+
// stays declarative and this line is the one place that says what is lost.
|
|
167
|
+
if (!cfg.log.enabled) {
|
|
168
|
+
for (const n of cfg.nodes) {
|
|
169
|
+
if (n.logFile) app.log({ level: 'info', msg: `node "${n.id}": ignoring logFile ${n.logFile} because the log source is disabled (BLOCKYARD_LOG_SOURCE=0)` });
|
|
170
|
+
n.logFile = null;
|
|
171
|
+
}
|
|
172
|
+
app.log({ level: 'warn', msg: 'log source DISABLED: running on RPC only -- which is the supported mode, and the only one for Bitcoin Core (the log parsers target an experimental node grammar; see docs/CONFIGURATION.md). Bandwidth and per-peer bytes work only on node builds that publish them (measured 2026-09-08: bench build 11.56 MB/s via getnettotals against 11.2 MB/s stated in its log; production build 0 bytes and getpeerinfo [] with 16 connections). Per-peer relay legs, served-block attribution, tx accept/reject counts, disk-write rate, worker bans, the node\'s own ETA, compaction/validation stalls and sync_failing have no RPC source at all.' });
|
|
173
|
+
} else {
|
|
174
|
+
// THE INVERSE CASE, said out loud. Turning the log source on against Bitcoin Core produces
|
|
175
|
+
// nothing useful and is easy to mistake for a configuration problem: logparse.js targets an
|
|
176
|
+
// experimental node's grammar ([dlc], [dl], [dial], [utxo_live], [config], and a
|
|
177
|
+
// "YYYY-MM-DD HH:MM:SS.mmm " timestamp), so Core's debug.log lines come back as unstructured
|
|
178
|
+
// `raw` events with no fields, timestamped when they were READ rather than when they were
|
|
179
|
+
// written. The boot log is the most visible place to say so before someone spends an evening
|
|
180
|
+
// wondering why the bandwidth chart is empty. (operator, 2026-09-13.)
|
|
181
|
+
app.log({
|
|
182
|
+
level: 'warn',
|
|
183
|
+
msg: 'log source ENABLED: note that log parsing does NOT support Bitcoin Core -- the parsers '
|
|
184
|
+
+ 'were written and tested against an experimental node implementation with a different log '
|
|
185
|
+
+ 'grammar. Against a Core debug.log every line is kept as an unstructured event with no '
|
|
186
|
+
+ 'figures extracted and a timestamp taken at read time, so panels that need the log stay '
|
|
187
|
+
+ 'empty and the event feed is misdated. Set BLOCKYARD_LOG_SOURCE=0 (the default) unless your '
|
|
188
|
+
+ 'node writes the format in test/fixtures/. See docs/CONFIGURATION.md, "RPC-only mode".',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const nodeCfg of cfg.nodes) {
|
|
193
|
+
// Cookie auth is impossible without the datadir, so a configured node whose
|
|
194
|
+
// datadir has gone (a benchmark directory that got cleaned up) is skipped
|
|
195
|
+
// with a reason rather than kept as a permanently-offline panel. A wrong
|
|
196
|
+
// "offline" is worse than an absent one: it invites someone to go fix a node
|
|
197
|
+
// that is running fine.
|
|
198
|
+
if (nodeCfg.datadir && !nodeCfg.cookieFile && !fs.existsSync(nodeCfg.datadir)) {
|
|
199
|
+
app.log({ level: nodeCfg.optional ? 'info' : 'warn', msg: `skipping node "${nodeCfg.id}": datadir ${nodeCfg.datadir} does not exist, so its RPC cookie cannot be read (remove it from config.nodes or point it at a live node)` });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const m = new NodeMonitor(nodeCfg, { rpc: cfg.rpc, poll: cfg.poll, store: cfg.store, log: app.log, history: app.history, logCfg: cfg.log, miningCfg: {
|
|
203
|
+
// Two cheap reads per block on the shared lane (measured 2026-09-09: 8 ms + 63 ms).
|
|
204
|
+
// BLOCKYARD_MINING=0 turns attribution off entirely; the sizes, fees and weights stay.
|
|
205
|
+
enabled: process.env.BLOCKYARD_MINING !== '0',
|
|
206
|
+
backfill: Number(process.env.BLOCKYARD_MINING_BACKFILL ?? 36),
|
|
207
|
+
// The block being built is assembled from the mempool the pool tier already reads
|
|
208
|
+
// (collect/gbt.js), so it costs the node no call of its own -- it used to be a
|
|
209
|
+
// getblocktemplate worth 1.3-1.5 s of the node's single RPC thread.
|
|
210
|
+
// BLOCKYARD_MINING_TEMPLATE=0 still turns the card off for anyone who does not want it.
|
|
211
|
+
template: process.env.BLOCKYARD_MINING_TEMPLATE !== '0',
|
|
212
|
+
perTick: 1,
|
|
213
|
+
// A human-edited tag -> label map. Absent by default, which is the correct state:
|
|
214
|
+
// the coinbase text is shown as the pool wrote it.
|
|
215
|
+
aliasesFile: path.join(cfg.store.dir, 'pool-aliases.json'),
|
|
216
|
+
// Written by `node scripts/pool-map.js` into data/; until someone runs it, the copy shipped in
|
|
217
|
+
// config/ (mempool.space/mining-pools, MIT, fetched 2026-09-09) -- a fresh install attributed
|
|
218
|
+
// nothing and showed raw coinbase tags (operator, 2026-09-14: "Block attribution is fucked up")
|
|
219
|
+
poolMapFile: process.env.BLOCKYARD_POOL_MAP ?? (fs.existsSync(path.join(cfg.store.dir, 'pool-map.json')) ? path.join(cfg.store.dir, 'pool-map.json') : path.join(ROOT, 'config', 'pool-map.json')),
|
|
220
|
+
} });
|
|
221
|
+
m.node = nodeCfg;
|
|
222
|
+
// Deliberately NOT `m.history = app.history`: the monitor wraps the shared store
|
|
223
|
+
// in a node-scoped view in its constructor, and reassigning the raw store here is
|
|
224
|
+
// what made every node's charts draw the average of all nodes.
|
|
225
|
+
wireMonitor(app, m);
|
|
226
|
+
app.monitors.set(nodeCfg.id, m);
|
|
227
|
+
await m.start();
|
|
228
|
+
}
|
|
229
|
+
app.primary = [...app.monitors.values()][0] ?? null;
|
|
230
|
+
|
|
231
|
+
// Exchange prices for the Markets tab: the one outbound connection that is not the node,
|
|
232
|
+
// and it polls only while someone has that tab open (server/collect/markets.js).
|
|
233
|
+
if (cfg.markets?.enabled) {
|
|
234
|
+
const { MarketFeed } = await import('./collect/markets.js');
|
|
235
|
+
app.markets = new MarketFeed(cfg.markets, { log: app.log });
|
|
236
|
+
} else app.markets = null;
|
|
237
|
+
|
|
238
|
+
app.auditLog = new AuditLog(path.join(cfg.store.dir, 'audit.jsonl'), {
|
|
239
|
+
maxBytes: cfg.store.auditMaxBytes,
|
|
240
|
+
keep: cfg.store.auditKeep,
|
|
241
|
+
log: app.log,
|
|
242
|
+
});
|
|
243
|
+
await app.auditLog.adopt();
|
|
244
|
+
// REDACT BY SHAPE, not by a list of two names. Deleting `password` and `rpcPassword` covered the
|
|
245
|
+
// fields today's routes happen to carry -- but the trail also stores action ARGUMENTS and a
|
|
246
|
+
// 200-character preview of action RESULTS, so the next action that echoes a key-shaped argument
|
|
247
|
+
// would write it into audit.jsonl for ever, where the whole point of the file is that it is kept.
|
|
248
|
+
// An audit on 2026-09-13 pointed at exactly that gap. Nested, because arguments are objects.
|
|
249
|
+
//
|
|
250
|
+
// `key` alone is deliberately NOT in the pattern: it would redact poolKey, labelKey and keylen,
|
|
251
|
+
// which are not secrets, and an audit trail full of [redacted] where the useful fields were is
|
|
252
|
+
// its own kind of failure.
|
|
253
|
+
const SECRETISH = /pass(word|phrase)?|secret|cookie|token|priv(ate)?_?key|seed|mnemonic|authorization|credential/i;
|
|
254
|
+
const redact = (v, depth = 0) => {
|
|
255
|
+
if (v == null || depth > 6) return v;
|
|
256
|
+
if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
|
|
257
|
+
if (typeof v !== 'object') return v;
|
|
258
|
+
const out = {};
|
|
259
|
+
for (const [k, val] of Object.entries(v)) out[k] = SECRETISH.test(k) ? '[redacted]' : redact(val, depth + 1);
|
|
260
|
+
return out;
|
|
261
|
+
};
|
|
262
|
+
app.audit = async (row) => {
|
|
263
|
+
const entry = redact({ at: Date.now(), ...row });
|
|
264
|
+
await app.auditLog.append(entry).catch((err) => app.log({ level: 'error', msg: `audit write failed: ${err.message}` }));
|
|
265
|
+
};
|
|
266
|
+
app.readAudit = async (limit = 100) => app.auditLog.read(limit);
|
|
267
|
+
|
|
268
|
+
app.access = ({ req, path: p, status, ms, ip, user = null, error = null }) => {
|
|
269
|
+
if (p === '/api/stream') return;
|
|
270
|
+
const slow = ms > 3000;
|
|
271
|
+
if (status >= 400 || slow) {
|
|
272
|
+
app.log({ level: status >= 500 ? 'error' : 'warn', msg: `${req.method} ${p} -> ${status} in ${ms}ms${error ? ` (${error})` : ''} user=${user ?? '-'} ip=${ip}` });
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
app.selfTelemetry = () => {
|
|
277
|
+
const mem = process.memoryUsage();
|
|
278
|
+
const cpu = process.cpuUsage(app.cpuStartBaseline ?? process.cpuUsage());
|
|
279
|
+
const wall = Date.now() - app.startedAt;
|
|
280
|
+
const cpuPct = wall > 0 ? ((cpu.user + cpu.system) / 1000 / wall) * 100 : 0;
|
|
281
|
+
app.cpuStartBaseline ||= process.cpuUsage();
|
|
282
|
+
const row = {
|
|
283
|
+
t: Date.now(),
|
|
284
|
+
rssMb: +(mem.rss / 1048576).toFixed(1),
|
|
285
|
+
heapMb: +(mem.heapUsed / 1048576).toFixed(1),
|
|
286
|
+
sseClients: app.hub.clients.size,
|
|
287
|
+
usersActive: app.sessions.active().users,
|
|
288
|
+
cpuPct: +cpuPct.toFixed(2),
|
|
289
|
+
eventRate: app.eventWindow?.rate ?? 0,
|
|
290
|
+
build: app.build,
|
|
291
|
+
};
|
|
292
|
+
app.selfRing.push(row);
|
|
293
|
+
if (app.selfRing.length > 5000) app.selfRing.splice(0, app.selfRing.length - 5000);
|
|
294
|
+
return row;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// One HTTP server per bound address, all sharing `app`. A single server cannot
|
|
298
|
+
// listen twice (ERR_SERVER_ALREADY_LISTEN), and the wildcard is exactly what we
|
|
299
|
+
// are avoiding, so the fan-out lives here. The rate limiter, sessions and monitors
|
|
300
|
+
// all live on `app`, so an attacker picking a different address gets the same
|
|
301
|
+
// budget, not another one. (StaticFiles is per server -- duplicated ETag state for
|
|
302
|
+
// a couple of hundred KB of assets, which is cheaper than pretending one socket
|
|
303
|
+
// can speak for two interfaces.)
|
|
304
|
+
app.servers = [];
|
|
305
|
+
app.server = null;
|
|
306
|
+
|
|
307
|
+
const hosts = cfg.server.hosts ?? [cfg.server.host];
|
|
308
|
+
// Binding is a decision with casualties, so make them visible: addresses the
|
|
309
|
+
// machine lacks are reported and skipped (a tunnel interface that comes up after
|
|
310
|
+
// us must not stop the monitor serving the interfaces that exist), and only
|
|
311
|
+
// "nothing here is bindable" is fatal. Loopback and the container bridges are
|
|
312
|
+
// excluded by construction when specific addresses are named -- and saying so at
|
|
313
|
+
// boot is what saves the next person an hour, because a refused 127.0.0.1 curl is
|
|
314
|
+
// indistinguishable from a dead monitor.
|
|
315
|
+
const plan = planBinds(hosts);
|
|
316
|
+
if (plan.noneUsable) {
|
|
317
|
+
app.log({ level: 'error', msg: bindProblemMessage({ err: { code: 'EADDRNOTAVAIL' }, host: plan.list.join(' | '), port: cfg.server.port }) });
|
|
318
|
+
process.exitCode = 1;
|
|
319
|
+
await app.history?.save?.().catch(() => {});
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
for (const missing of plan.missing) {
|
|
323
|
+
app.log({
|
|
324
|
+
level: 'warn',
|
|
325
|
+
msg: `not binding ${missing}: this machine has no such address right now. If it is a tunnel (tailscale0), start this unit after tailscaled.service, or drop it from server.hosts. Continuing on: ${plan.bindable.join(', ')}`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
for (const host of plan.bindable) {
|
|
329
|
+
const srv = createAppServer(app);
|
|
330
|
+
app.servers.push(srv);
|
|
331
|
+
if (!app.server) app.server = srv;
|
|
332
|
+
await new Promise((resolve, reject) => {
|
|
333
|
+
srv.once('error', reject);
|
|
334
|
+
srv.listen(cfg.server.port, host, resolve);
|
|
335
|
+
}).catch((err) => {
|
|
336
|
+
app.log({ level: 'error', msg: bindProblemMessage({ err, host, port: cfg.server.port }) });
|
|
337
|
+
// Anything already listening must not be left serving half the addresses --
|
|
338
|
+
// "reachable on the LAN but not the tailnet" is worse than a clean failure.
|
|
339
|
+
for (const s of app.servers) { try { s.close(); } catch { /* already gone */ } }
|
|
340
|
+
process.exit(1);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const served = plan.bindable.map((h) => `${app.scheme}://${h}:${cfg.server.port}`);
|
|
344
|
+
app.log({ level: 'info', msg: `BlockYard ${VERSION} listening on ${served.join(' and ')}` });
|
|
345
|
+
if (!plan.bindable.includes('0.0.0.0') && !plan.bindable.includes('::')) {
|
|
346
|
+
const v4 = localAddresses().filter((a) => a.family === 'IPv4' && !plan.bindable.includes(a.address) && !a.internal);
|
|
347
|
+
const v6 = localAddresses().filter((a) => a.family === 'IPv6' && !plan.bindable.includes(a.address) && !a.internal).length;
|
|
348
|
+
// warn, not info: when loopback is not among the bound addresses, "connection
|
|
349
|
+
// refused from the machine itself" is the confusion this line exists to prevent,
|
|
350
|
+
// and tooling that runs at LOG_LEVEL=warn (scripts/smoke.sh) would never see it at
|
|
351
|
+
// info. That silencing cost a false "54 failures" reading today.
|
|
352
|
+
app.log({
|
|
353
|
+
level: 'warn',
|
|
354
|
+
msg: `bound to specific interfaces -- NOT reachable on ${v4.map((a) => `${a.address} (${a.name})`).join(', ') || 'other IPv4 addresses'}`
|
|
355
|
+
+ `${v6 ? ` (plus ${v6} IPv6 address(es))` : ''}, and not on 127.0.0.1 either: use one of the addresses above from this machine too.`,
|
|
356
|
+
});
|
|
357
|
+
if (plan.missing.length) {
|
|
358
|
+
app.log({ level: 'warn', msg: `skipped at boot: ${plan.missing.join(', ')} -- clients that would have used those addresses will get "connection refused", which is not a crash` });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Periodic series refresh and self-telemetry, plus an event-rate window the
|
|
363
|
+
// telemetry panel uses so "how much is this app doing" is answerable.
|
|
364
|
+
app.eventWindow = { count: 0, since: Date.now(), rate: 0 };
|
|
365
|
+
app.timers = [
|
|
366
|
+
setInterval(() => {
|
|
367
|
+
const now = Date.now();
|
|
368
|
+
const secs = (now - app.eventWindow.since) / 1000;
|
|
369
|
+
app.eventWindow.rate = secs > 0 ? +(app.eventWindow.count / secs).toFixed(2) : 0;
|
|
370
|
+
app.eventWindow.count = 0;
|
|
371
|
+
app.eventWindow.since = now;
|
|
372
|
+
app.selfTelemetry();
|
|
373
|
+
if (app.history.dirtySince && now - app.history.dirtySince > 30_000) app.history.prune();
|
|
374
|
+
}, 10_000),
|
|
375
|
+
setInterval(() => {
|
|
376
|
+
const n = app.sessions.sweep();
|
|
377
|
+
if (n) app.sessions.save().catch(() => {});
|
|
378
|
+
}, 60_000),
|
|
379
|
+
];
|
|
380
|
+
app.timers.forEach((t) => t.unref?.());
|
|
381
|
+
|
|
382
|
+
// Chart series change slowly; pushing them on every snapshot would be most of
|
|
383
|
+
// the bandwidth for almost no benefit.
|
|
384
|
+
const seriesPush = setInterval(() => {
|
|
385
|
+
if (!app.hub.clients.size) return;
|
|
386
|
+
for (const m of app.monitors.values()) {
|
|
387
|
+
app.hub.pushSeries({ node: m.id, series: m.seriesView({}) }, { nodeId: m.id });
|
|
388
|
+
}
|
|
389
|
+
}, 20_000);
|
|
390
|
+
seriesPush.unref?.();
|
|
391
|
+
app.timers.push(seriesPush);
|
|
392
|
+
|
|
393
|
+
// THE ADDRESS INDEX FOLLOWS THE CHAIN (server/chain/index/live.js): one follower per index
|
|
394
|
+
// directory, fed by a node that uses it -- one with its block files on this machine if there is
|
|
395
|
+
// one, since that is the node the index was built from. A follower that cannot open its index
|
|
396
|
+
// logs why and the address page says the same; nothing else is held up by it.
|
|
397
|
+
const followers = new Map();
|
|
398
|
+
for (const m of app.monitors.values()) {
|
|
399
|
+
const dir = m.cfg?.addressIndex;
|
|
400
|
+
if (!dir) continue;
|
|
401
|
+
const had = followers.get(dir);
|
|
402
|
+
if (!had || (!had.cfg?.datadir && m.cfg?.datadir)) followers.set(dir, m);
|
|
403
|
+
}
|
|
404
|
+
if (followers.size) {
|
|
405
|
+
const { LiveIndex } = await import('./chain/index/live.js');
|
|
406
|
+
const { registerLiveIndex, registerIndexBuild } = await import('./http/explorer.js');
|
|
407
|
+
const { buildIndex, defaultWorkers, rpcPacer } = await import('./chain/index/build.js');
|
|
408
|
+
const follow = (dir, m) => {
|
|
409
|
+
const live = new LiveIndex(dir, { rpc: m.rpc, nodeId: m.id, log: { info: (msg) => app.log({ level: 'info', msg }), warn: (msg) => app.log({ level: 'warn', msg }) } });
|
|
410
|
+
registerLiveIndex(dir, live);
|
|
411
|
+
const tick = () => { live.poll().catch(() => {}); };
|
|
412
|
+
tick();
|
|
413
|
+
const t = setInterval(tick, 30_000);
|
|
414
|
+
t.unref?.();
|
|
415
|
+
app.timers.push(t);
|
|
416
|
+
app.log({ level: 'info', msg: `address index ${dir}: following ${m.id} from block ${live.tip}` });
|
|
417
|
+
};
|
|
418
|
+
// A MISSING INDEX IS BUILT HERE, IN THE BACKGROUND (operator, 2026-09-14: "Is it possible to run
|
|
419
|
+
// step 6 in the background, and have a status notification in blockyard when the index process
|
|
420
|
+
// is finished?"). The build runs on worker threads inside this process while every page keeps
|
|
421
|
+
// serving; its progress is a quality flag the Overview shows and the address page repeats; an
|
|
422
|
+
// event -- which the browser toasts -- marks the start, the finish, or a failure. A node config
|
|
423
|
+
// can say addressIndexBuild: "manual" to keep this from happening.
|
|
424
|
+
const HMS = (sec) => (sec < 90 ? `${Math.round(sec)} s` : sec < 5400 ? `${Math.round(sec / 60)} min` : `${(sec / 3600).toFixed(1)} h`);
|
|
425
|
+
const build = async (dir, m) => {
|
|
426
|
+
// at most four, and half what a dedicated build would take: the node shares this machine's disk
|
|
427
|
+
// (an external one, on the first Mac), and the pacer only sees trouble after it has started
|
|
428
|
+
// ...or the number the config names: on spinning disks one reader is the fast one (parallel
|
|
429
|
+
// readers seek against each other and against the node), so addressIndexWorkers: 1 there
|
|
430
|
+
const workers = Number.isInteger(m.cfg?.addressIndexWorkers) && m.cfg.addressIndexWorkers > 0 ? m.cfg.addressIndexWorkers : Math.max(1, Math.min(4, Math.floor(defaultWorkers() / 2)));
|
|
431
|
+
// ITS OWN CONNECTION (2026-09-14, the first Mac: the build's getblockhash batches sat at the back
|
|
432
|
+
// of the monitor's one-in-flight lane behind multi-second mempool and block reads, and both
|
|
433
|
+
// starved -- "heights 1,000 of 967,015" for a quarter of an hour). The build's calls are cheap
|
|
434
|
+
// and few, on a second lane, exactly as scripts/index-build.js has always run; the pacer still
|
|
435
|
+
// reads the monitor's lane, which is the measure of how the node is coping.
|
|
436
|
+
const { RpcClient } = await import('./rpc/client.js');
|
|
437
|
+
const quiet = { info() {}, warn() {}, error() {}, debug() {} };
|
|
438
|
+
const rpc = new RpcClient(m.cfg, { ...(cfg.rpc ?? {}), ...(m.cfg.rpc ?? {}) }, { log: quiet });
|
|
439
|
+
const status = { dir, node: m.id, phase: 'starting', done: 0, total: 0, rows: 0, eta: null, startedAt: Date.now(), error: null, paused: false };
|
|
440
|
+
registerIndexBuild(dir, status);
|
|
441
|
+
m.indexBuild = status;
|
|
442
|
+
// PACED BY THE NODE'S OWN ANSWERS: the workers read the block files the node is also reading, so
|
|
443
|
+
// when its RPC slows past the monitor's own threshold (rpc.slowLatencyMs, 5 s) the next file waits
|
|
444
|
+
// until it recovers (2026-09-14, the first Mac install: 18 s answers and 90 s timeouts while the
|
|
445
|
+
// build ran flat out -- which turned out to be gettxoutsetinfo, not the build, but the pacing stays)
|
|
446
|
+
const pace = rpcPacer(m.rpc, { slowMs: cfg.rpc?.slowLatencyMs ?? 5000, onChange: (held, t) => {
|
|
447
|
+
status.paused = held;
|
|
448
|
+
app.log({ level: 'info', msg: held ? `address index build: paused while the node's RPC is ${t.breakerOpen ? 'refused' : t.lastError ? 'failing' : `answering in ${((t.avgLatencyMs ?? 0) / 1000).toFixed(1)} s`}` : 'address index build: resumed' });
|
|
449
|
+
} });
|
|
450
|
+
const say = (text, severity = 'info') => { m.addEvent?.({ kind: 'index', severity, tag: 'index', ts: Date.now(), text }); app.log({ level: severity === 'warn' ? 'warn' : 'info', msg: text }); };
|
|
451
|
+
say(`address index: building ${dir} from ${m.id}'s block files with ${workers} workers -- the Overview shows the progress`);
|
|
452
|
+
let phase = null, phaseAt = Date.now(), lastFlag = 0;
|
|
453
|
+
buildIndex({
|
|
454
|
+
rpc, blocksDir: path.join(m.cfg.datadir, 'blocks'), out: dir, workers, pace,
|
|
455
|
+
onProgress: (p) => {
|
|
456
|
+
if (p.phase !== phase) { phase = p.phase; phaseAt = Date.now(); }
|
|
457
|
+
const elapsed = (Date.now() - phaseAt) / 1000;
|
|
458
|
+
const rate = elapsed > 0 && p.done > 0 ? p.done / elapsed : 0;
|
|
459
|
+
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 });
|
|
460
|
+
if (Date.now() - lastFlag > 5000) {
|
|
461
|
+
lastFlag = Date.now();
|
|
462
|
+
const pct = p.total ? Math.round((100 * p.done) / p.total) : 0;
|
|
463
|
+
m.flagQuality?.('address-index-building', `the address index is being built: ${p.phase} ${p.done.toLocaleString()} of ${p.total.toLocaleString()} (${pct}%)${p.rows ? `, ${p.rows.toLocaleString()} rows so far` : ''}${status.eta ? `, about ${status.eta} left` : ''}${status.paused ? ' -- paused while the node\'s RPC is slow' : ''}`, 'info');
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
}).then((manifest) => {
|
|
467
|
+
registerIndexBuild(dir, null);
|
|
468
|
+
m.indexBuild = null;
|
|
469
|
+
m.clearQuality?.('address-index-building');
|
|
470
|
+
const mins = ((Date.now() - status.startedAt) / 60000).toFixed(1);
|
|
471
|
+
say(`address index built: ${Number(manifest.rows ?? 0).toLocaleString()} rows to block ${Number(manifest.tip?.height ?? 0).toLocaleString()} in ${mins} min -- address pages are live`);
|
|
472
|
+
try { follow(dir, m); } catch (err) { say(`address index ${dir}: built, but the follower could not start: ${err.message}`, 'warn'); }
|
|
473
|
+
}).catch((err) => {
|
|
474
|
+
status.error = err.message;
|
|
475
|
+
registerIndexBuild(dir, null);
|
|
476
|
+
m.indexBuild = null;
|
|
477
|
+
m.flagQuality?.('address-index-build-failed', `the address index build failed: ${err.message} -- fix the cause and run node scripts/index-build.js --out ${dir}`, 'warn');
|
|
478
|
+
say(`address index build failed: ${err.message}`, 'warn');
|
|
479
|
+
});
|
|
480
|
+
};
|
|
481
|
+
for (const [dir, m] of followers) {
|
|
482
|
+
const finished = fs.existsSync(path.join(dir, 'manifest.json'));
|
|
483
|
+
if (finished) {
|
|
484
|
+
try { follow(dir, m); } catch (err) { app.log({ level: 'warn', msg: `address index ${dir}: ${err.message}` }); }
|
|
485
|
+
} else if (m.cfg?.addressIndexBuild === 'manual') {
|
|
486
|
+
app.log({ level: 'warn', msg: `address index ${dir}: not built, and addressIndexBuild is "manual" -- run node scripts/index-build.js --out ${dir}` });
|
|
487
|
+
} else if (!m.cfg?.datadir) {
|
|
488
|
+
app.log({ level: 'warn', msg: `address index ${dir}: not built, and node ${m.id} has no datadir to build it from` });
|
|
489
|
+
} else build(dir, m).catch((err) => app.log({ level: 'warn', msg: `address index ${dir}: ${err.message}` }));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
installShutdown(app);
|
|
494
|
+
return app;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function wireMonitor(app, m) {
|
|
498
|
+
let pushTimer = null;
|
|
499
|
+
const schedulePush = () => {
|
|
500
|
+
if (pushTimer) return;
|
|
501
|
+
// Coalesce to one snapshot push per second per node: several tiers can
|
|
502
|
+
// finish inside the same tick, and the browser only ever wants the newest.
|
|
503
|
+
pushTimer = setTimeout(() => {
|
|
504
|
+
pushTimer = null;
|
|
505
|
+
if (!app.hub.clients.size) return;
|
|
506
|
+
try {
|
|
507
|
+
const snap = m.snapshot({});
|
|
508
|
+
app.stateSeq += 1;
|
|
509
|
+
snap.seq = app.stateSeq;
|
|
510
|
+
app.hub.pushSnapshot(snap, { nodeId: m.id });
|
|
511
|
+
} catch (err) {
|
|
512
|
+
app.log({ level: 'error', msg: `snapshot failed: ${err.stack ?? err.message}` });
|
|
513
|
+
}
|
|
514
|
+
}, 1000);
|
|
515
|
+
pushTimer.unref?.();
|
|
516
|
+
};
|
|
517
|
+
m.on('changed', schedulePush);
|
|
518
|
+
m.on('blocks', schedulePush);
|
|
519
|
+
m.on('events', (rows) => {
|
|
520
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
521
|
+
app.eventWindow.count += list.length;
|
|
522
|
+
for (const r of list) {
|
|
523
|
+
// The raw chatter (per-tick bandwidth, heartbeat) is stored for charts but
|
|
524
|
+
// not pushed as a feed line, or the event list buries its own news.
|
|
525
|
+
if (r.kind === 'raw' && r.severity === 'info' && !/error|fail|warn|drop|refus|reject|unreachable|banned|connect/i.test(r.text ?? '')) continue;
|
|
526
|
+
app.hub.pushEvent(r, { nodeId: m.id });
|
|
527
|
+
}
|
|
528
|
+
schedulePush();
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function makeLogger(cfg) {
|
|
533
|
+
const levels = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
534
|
+
const threshold = levels[cfg.log.level] ?? 20;
|
|
535
|
+
const log = (entry) => {
|
|
536
|
+
const e = typeof entry === 'string' ? { msg: entry } : entry;
|
|
537
|
+
const level = e.level ?? 'info';
|
|
538
|
+
if ((levels[level] ?? 20) < threshold) return;
|
|
539
|
+
const ctx = e.node ? ` [${e.node}]` : '';
|
|
540
|
+
const line = `${new Date().toISOString().replace('T', ' ').slice(0, 19)} ${level.toUpperCase().padEnd(5)}${ctx} ${e.msg}`;
|
|
541
|
+
// Never let a credential reach stdout, where a journald capture would keep it
|
|
542
|
+
// forever -- including a fake-node cookie in a dev URL.
|
|
543
|
+
process.stdout.write(line.replace(/(:[^:@\s]{16,}@)/g, ':***@').replace(/(password|secret|cookie)["']=([^"']{6,})/gi, '$1=***') + '\n');
|
|
544
|
+
};
|
|
545
|
+
log.child = (base) => (entry) => {
|
|
546
|
+
const e = typeof entry === 'string' ? { msg: entry } : entry;
|
|
547
|
+
log({ ...base, ...e });
|
|
548
|
+
};
|
|
549
|
+
return log;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function installShutdown(app) {
|
|
553
|
+
let closing = false;
|
|
554
|
+
// Exposed as app.shutdown() rather than living only in the signal handler, for a
|
|
555
|
+
// boring reason: an HTTP-level test that boots the real app in-process had no way
|
|
556
|
+
// to stop it, so every test that wanted to speak HTTP had to shell out to
|
|
557
|
+
// scripts/smoke.sh. Teardown being unreachable is why this repo had no in-process
|
|
558
|
+
// integration test for sessions, headers or the login throttle.
|
|
559
|
+
const close = async ({ saveHistory = true } = {}) => {
|
|
560
|
+
if (closing) return { alreadyClosed: true };
|
|
561
|
+
closing = true;
|
|
562
|
+
for (const t of app.timers ?? []) clearInterval(t);
|
|
563
|
+
app.hub?.closeAll();
|
|
564
|
+
for (const m of app.monitors.values()) await m.stop().catch(() => {});
|
|
565
|
+
app.markets?.stop();
|
|
566
|
+
if (saveHistory) await app.history?.stop?.().catch((err) => app.log({ level: 'error', msg: `history save failed: ${err.message}` }));
|
|
567
|
+
await app.sessions?.save?.().catch(() => {});
|
|
568
|
+
await new Promise((r) => {
|
|
569
|
+
const open = (app.servers ?? [app.server]).filter(Boolean);
|
|
570
|
+
if (!open.length) return r();
|
|
571
|
+
let left = open.length;
|
|
572
|
+
for (const s of open) s.close(() => { if (--left <= 0) r(); });
|
|
573
|
+
});
|
|
574
|
+
if (app.fakeNode) await app.fakeNode.stop().catch(() => {});
|
|
575
|
+
return { closed: true };
|
|
576
|
+
};
|
|
577
|
+
app.shutdown = close;
|
|
578
|
+
|
|
579
|
+
const bye = async (sig) => {
|
|
580
|
+
if (closing) return;
|
|
581
|
+
app.log({ level: 'info', msg: `${sig}: shutting down` });
|
|
582
|
+
await close();
|
|
583
|
+
app.log({ level: 'info', msg: 'bye' });
|
|
584
|
+
// Do not hang on a socket that refused to close; history is already durable.
|
|
585
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
586
|
+
};
|
|
587
|
+
process.on('SIGINT', () => bye('SIGINT'));
|
|
588
|
+
process.on('SIGTERM', () => bye('SIGTERM'));
|
|
589
|
+
process.on('unhandledRejection', (err) => app.log({ level: 'error', msg: `unhandled rejection: ${err?.stack ?? err}` }));
|
|
590
|
+
process.on('uncaughtException', (err) => {
|
|
591
|
+
app.log({ level: 'error', msg: `uncaught exception: ${err?.stack ?? err}` });
|
|
592
|
+
setTimeout(() => process.exit(1), 300);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function banner(app) {
|
|
597
|
+
const lines = [];
|
|
598
|
+
const host = app.cfg.server.host;
|
|
599
|
+
lines.push('');
|
|
600
|
+
lines.push(' BlockYard is up');
|
|
601
|
+
lines.push(` URL ${app.scheme}://${host === '0.0.0.0' ? 'localhost' : host}:${app.cfg.server.port} (build ${app.build})`);
|
|
602
|
+
if (app.bootstrap) {
|
|
603
|
+
lines.push(` login ${app.bootstrap.username} / ${app.bootstrap.password}`);
|
|
604
|
+
lines.push(` ${app.bootstrap.generated ? 'generated now, shown once, stored only as a scrypt hash' : 'taken from BLOCKYARD_ADMIN_PASSWORD'}`);
|
|
605
|
+
} else if (app.cfg.auth.enabled) {
|
|
606
|
+
lines.push(' login your usual account');
|
|
607
|
+
} else {
|
|
608
|
+
// Same content as the boot warning, in the banner: the first thing on screen
|
|
609
|
+
// after `npm start` should be the sentence about who can read the node.
|
|
610
|
+
lines.push(' login DISABLED — open to anyone who can reach the addresses above (role: viewer, read-only)');
|
|
611
|
+
lines.push(' user admin, the audit trail and node writes stay closed; BLOCKYARD_AUTH=1 turns accounts on');
|
|
612
|
+
}
|
|
613
|
+
lines.push(` nodes ${[...app.monitors.values()].map((m) => `${m.id} -> ${m.rpc.url}`).join(', ')}`);
|
|
614
|
+
if (app.cfg.server.allowCidrs.length) lines.push(` CIDRs ${app.cfg.server.allowCidrs.join(', ')}`);
|
|
615
|
+
lines.push(` actions ${app.cfg.actions.enabled ? `enabled: ${app.cfg.actions.allow.join(', ') || '(none listed)'}` : 'disabled (read-only)'}`);
|
|
616
|
+
lines.push('');
|
|
617
|
+
return lines.join('\n');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// run-as-main, portably: on Windows a file URL's path is "/C:/x" and realpath gives "C:\\x", so the
|
|
621
|
+
// two are compared as paths, never as strings (2026-09-14, the first Windows CI run)
|
|
622
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]);
|
|
623
|
+
if (isMain) {
|
|
624
|
+
const app = await boot();
|
|
625
|
+
process.stdout.write(banner(app) + '\n');
|
|
626
|
+
// The banner is also the only place the bootstrap password ever appears.
|
|
627
|
+
if (app.bootstrap) await app.audit({ type: 'bootstrap-admin', generated: app.bootstrap.generated, ip: 'local' });
|
|
628
|
+
}
|