blockyard 0.0.9 → 0.1.1

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -10,6 +10,20 @@
10
10
  // are coalesced per client -- at most one snapshot in flight, latest wins -- and
11
11
  // the event feed is batched. A client that cannot keep up sees a coarser stream,
12
12
  // never an unbounded queue that takes the process down.
13
+ //
14
+ // THAT LAST SENTENCE WAS NOT TRUE UNTIL 2026-09-16 (audit H1). Coalescing only helps if nothing is
15
+ // written while the socket is full, and flush() wrote every pending frame regardless: `write()`
16
+ // noted `backpressured` and nothing ever read it. The reaper removed a client only if it had been
17
+ // sent zero bytes, which no client ever has. So a reader that stopped reading kept every snapshot
18
+ // queued in Node's socket buffer: 400 stalled connections took a test instance from 73 MB to 1.5 GB
19
+ // in three minutes. Now a backpressured client is sent nothing (its pending snapshot keeps being
20
+ // replaced, latest wins) until the socket drains; one whose buffer passes a ceiling, or that stays
21
+ // blocked past a deadline, is dropped; and one address holds a bounded number of streams.
22
+ export const SSE_LIMITS = Object.freeze({
23
+ maxBufferedBytes: 4 * 1024 * 1024, // well above one snapshot; a client this far behind is not reading
24
+ maxBlockedMs: 60_000, // blocked for a minute: an EventSource reconnects on its own
25
+ maxPerKey: 16, // streams per address (open mode) or per account
26
+ });
13
27
  class Client {
14
28
  constructor(res, user, id) {
15
29
  this.res = res;
@@ -25,13 +39,17 @@ class Client {
25
39
  this.alive = true;
26
40
  this.lastWriteAt = Date.now();
27
41
  this.nodeId = null;
42
+ this.key = null;
43
+ this.backpressured = false;
44
+ this.blockedSince = 0;
28
45
  }
29
46
 
30
47
  get dead() { return !this.alive || this.res.writableEnded || this.res.destroyed; }
31
48
  }
32
49
 
33
50
  export class StreamHub {
34
- constructor({ log = () => {} } = {}) {
51
+ constructor({ log = () => {}, limits = {} } = {}) {
52
+ this.limits = { ...SSE_LIMITS, ...limits };
35
53
  this.clients = new Set();
36
54
  this.log = log;
37
55
  this.seq = 0;
@@ -39,10 +57,18 @@ export class StreamHub {
39
57
  this.timer = null;
40
58
  }
41
59
 
42
- add(req, res, { user = null, nodeId = null } = {}) {
60
+ /** How many open streams share this key (an address in open mode, an account otherwise). */
61
+ countFor(key) {
62
+ let n = 0;
63
+ for (const c of this.clients) if (c.key === key && !c.dead) n += 1;
64
+ return n;
65
+ }
66
+
67
+ add(req, res, { user = null, nodeId = null, key = null } = {}) {
43
68
  const id = ++this.seq;
44
69
  const client = new Client(res, user, id);
45
70
  client.nodeId = nodeId;
71
+ client.key = key;
46
72
  res.writeHead(200, {
47
73
  'Content-Type': 'text/event-stream; charset=utf-8',
48
74
  'Cache-Control': 'no-cache, no-store, must-revalidate',
@@ -55,6 +81,11 @@ export class StreamHub {
55
81
  req.on('close', close);
56
82
  req.on('aborted', close);
57
83
  res.on('error', close);
84
+ res.on('drain', () => {
85
+ client.backpressured = false;
86
+ client.blockedSince = 0;
87
+ this.flush();
88
+ });
58
89
  if (!this.timer) this.startHeartbeat();
59
90
  return client;
60
91
  }
@@ -70,24 +101,35 @@ export class StreamHub {
70
101
  const tick = () => {
71
102
  for (const c of [...this.clients]) {
72
103
  if (c.dead) { this.remove(c); continue; }
104
+ if (this.stalled(c)) continue;
73
105
  // A comment frame keeps intermediaries from reaping an idle stream and
74
- // lets us notice a dead socket on our own terms.
75
- if (this.write(c, ': ping\n\n')) {
76
- if (Date.now() - c.lastWriteAt > 120_000 && c.bytes === 0) this.remove(c);
77
- }
106
+ // lets us notice a dead socket on our own terms. Never onto a full socket.
107
+ if (!c.backpressured) this.write(c, ': ping\n\n');
78
108
  }
79
109
  };
80
110
  this.timer = setInterval(tick, this.heartbeatMs);
81
111
  this.timer.unref?.();
82
112
  }
83
113
 
114
+ /** Drop a client that is too far behind, or blocked too long; true if it was dropped. */
115
+ stalled(client) {
116
+ const buffered = client.res.writableLength ?? 0;
117
+ const blockedFor = client.backpressured && client.blockedSince ? Date.now() - client.blockedSince : 0;
118
+ if (buffered <= this.limits.maxBufferedBytes && blockedFor <= this.limits.maxBlockedMs) return false;
119
+ this.log({ level: 'warn', msg: `sse #${client.id} dropped: not reading (${Math.round(buffered / 1024)} KB buffered, blocked ${Math.round(blockedFor / 1000)}s)` });
120
+ this.remove(client);
121
+ try { client.res.destroy(); } catch { /* already gone */ }
122
+ return true;
123
+ }
124
+
84
125
  write(client, frame) {
85
126
  if (client.dead) return false;
86
127
  try {
87
128
  const ok = client.res.write(frame);
88
129
  client.bytes += frame.length;
89
130
  client.lastWriteAt = Date.now();
90
- if (!ok) client.backpressured = true;
131
+ if (!ok && !client.backpressured) { client.backpressured = true; client.blockedSince = Date.now(); }
132
+ if (!ok) this.stalled(client);
91
133
  return ok;
92
134
  } catch (err) {
93
135
  this.log({ level: 'debug', msg: `sse write failed: ${err.message}` });
@@ -142,6 +184,9 @@ export class StreamHub {
142
184
  this.flushing = false;
143
185
  for (const c of [...this.clients]) {
144
186
  if (c.dead) { this.remove(c); continue; }
187
+ // A full socket gets nothing more: what is pending stays pending (and is replaced by
188
+ // newer state) until 'drain' flushes again.
189
+ if (c.backpressured) { this.stalled(c); continue; }
145
190
  if (c.pendingSnapshot !== null) {
146
191
  const snap = c.pendingSnapshot;
147
192
  c.pendingSnapshot = null;
@@ -166,6 +211,7 @@ export class StreamHub {
166
211
  perClient: [...this.clients].map((c) => ({
167
212
  id: c.id, user: c.user?.username ?? null, seconds: Math.round((Date.now() - c.connectedAt) / 1000),
168
213
  kb: Math.round(c.bytes / 1024), dropped: { ...c.dropped }, node: c.nodeId,
214
+ backpressured: c.backpressured, bufferedKb: Math.round((c.res.writableLength ?? 0) / 1024),
169
215
  })),
170
216
  };
171
217
  }
package/server/main.js CHANGED
@@ -12,6 +12,9 @@ import { createAppServer } from './http/server.js';
12
12
  import { computeBuildId } from './http/static.js';
13
13
  import { NodeMonitor } from './collect/monitor.js';
14
14
  import { localAddresses, bindProblemMessage, planBinds } from './netinfo.js';
15
+ import { ensureSelfSigned } from './tls/selfsigned.js';
16
+ import { inspectTls } from './config.js';
17
+ import os from 'node:os';
15
18
  import { fileURLToPath } from 'node:url';
16
19
 
17
20
  // ONE PLACE, NOT TWO. This was a literal here AND a "version" field in package.json, and on
@@ -23,8 +26,9 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf
23
26
 
24
27
  export async function boot({ configFile, log: logOverride = null } = {}) {
25
28
  const cfg = loadConfig({ configFile });
26
- await fsp.mkdir(cfg.store.dir, { recursive: true });
27
- await fsp.mkdir(cfg.auth.dataDir, { recursive: true });
29
+ // owner-only where this creates them (audit 2026-09-16, L10); an existing directory keeps its mode
30
+ await fsp.mkdir(cfg.store.dir, { recursive: true, mode: 0o700 });
31
+ await fsp.mkdir(cfg.auth.dataDir, { recursive: true, mode: 0o700 });
28
32
 
29
33
  const app = {
30
34
  version: VERSION,
@@ -49,6 +53,8 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
49
53
  ),
50
54
  startedAt: Date.now(),
51
55
  publicDir: path.join(ROOT, 'public'),
56
+ // the DOS Diversions' game files, a directory a game, where the operator put them (http/games.js)
57
+ gamesDir: path.join(ROOT, 'games'),
52
58
  monitors: new Map(),
53
59
  stateSeq: 0,
54
60
  rssStart: process.memoryUsage().rss,
@@ -66,6 +72,18 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
66
72
  // Decided here, before any listener exists, and the cookie follows it: a Secure
67
73
  // cookie on an HTTP listener is a cookie the browser will not send, which reads
68
74
  // as "login keeps failing" -- so the two settings must not be independently set.
75
+ // HTTPS BY DEFAULT (2026-09-15): no certificate named means the monitor's own, made here on
76
+ // first start under <data>/tls and kept, naming every address it can be reached on -- the
77
+ // bound hosts, this machine's addresses, its hostname, localhost -- and remade when it nears
78
+ // expiry or stops naming a bound host. BLOCKYARD_TLS=0 is plain HTTP.
79
+ if (cfg.__tlsAuto) {
80
+ const bound = (cfg.server.hosts ?? [cfg.server.host]).filter((h) => h && h !== '0.0.0.0' && h !== '::');
81
+ const sans = ['localhost', os.hostname(), '127.0.0.1', '::1', ...bound, ...localAddresses().map((a) => a.address)];
82
+ const made = ensureSelfSigned(path.join(cfg.store.dir, 'tls'), { sans, mustName: bound });
83
+ cfg.server.tls.cert = made.certFile; cfg.server.tls.key = made.keyFile;
84
+ if (made.made) app.log({ level: 'warn', msg: `made this monitor's own self-signed certificate (${made.why}) at ${made.certFile}, valid to ${new Date(made.notAfter).toISOString().slice(0, 10)}, for ${made.sans.join(', ')} -- browsers warn once per address; name your own with BLOCKYARD_TLS_CERT/KEY, or BLOCKYARD_TLS=0 behind a proxy that terminates TLS` });
85
+ inspectTls(cfg, cfg.server.tls); // fingerprint, expiry note, self-signed flag for the log line below
86
+ }
69
87
  app.tls = Boolean(cfg.server.tls?.cert && cfg.server.tls?.key);
70
88
  if (app.tls) {
71
89
  app.tlsOptions = {
@@ -77,7 +95,8 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
77
95
  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
96
  }
79
97
  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) {
98
+ } else if (cfg.auth.enabled && !(cfg.server.hosts ?? [cfg.server.host]).every((h) => LOOPBACK.has(h))) {
99
+ // (not said for a loopback-only bind -- the default now -- where nothing crosses the LAN)
81
100
  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
101
  }
83
102
  app.scheme = app.tls ? 'https' : 'http';
@@ -128,7 +147,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
128
147
  const where = (cfg.server.hosts ?? [cfg.server.host]).join(', ') || '(wildcard)';
129
148
  app.log({
130
149
  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).`,
150
+ msg: `NO SIGN-IN (auth.enabled=false -- accounts were switched off; they are on out of the box): 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 (BLOCKYARD_AUTH=1, or drop the override, for accounts, roles, sessions and CSRF).`,
132
151
  });
133
152
  }
134
153
 
@@ -251,7 +270,12 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
251
270
  // which are not secrets, and an audit trail full of [redacted] where the useful fields were is
252
271
  // its own kind of failure.
253
272
  const SECRETISH = /pass(word|phrase)?|secret|cookie|token|priv(ate)?_?key|seed|mnemonic|authorization|credential/i;
273
+ // AND CLAMP BY LENGTH (audit 2026-09-16, M6). A caller-chosen string went into the trail whole:
274
+ // a 900 KB RPC method name, sixty times over, rotated every retained file and took the sign-ins and
275
+ // user changes with it. No field the trail needs is anywhere near this long.
276
+ const AUDIT_STRING_MAX = 1024;
254
277
  const redact = (v, depth = 0) => {
278
+ if (typeof v === 'string' && v.length > AUDIT_STRING_MAX) return `${v.slice(0, AUDIT_STRING_MAX)}…[${v.length - AUDIT_STRING_MAX} more chars]`;
255
279
  if (v == null || depth > 6) return v;
256
280
  if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
257
281
  if (typeof v !== 'object') return v;
@@ -342,7 +366,10 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
342
366
  }
343
367
  const served = plan.bindable.map((h) => `${app.scheme}://${h}:${cfg.server.port}`);
344
368
  app.log({ level: 'info', msg: `BlockYard ${VERSION} listening on ${served.join(' and ')}` });
345
- if (!plan.bindable.includes('0.0.0.0') && !plan.bindable.includes('::')) {
369
+ const loopbackOnly = plan.bindable.every((h) => LOOPBACK.has(h));
370
+ if (loopbackOnly) {
371
+ app.log({ level: 'info', msg: 'bound to this machine only (the default): reach it from elsewhere over an SSH tunnel, or bind a LAN address with BLOCKYARD_BIND / server.hosts -- docs/INSTALL.md §7' });
372
+ } else if (!plan.bindable.includes('0.0.0.0') && !plan.bindable.includes('::')) {
346
373
  const v4 = localAddresses().filter((a) => a.family === 'IPv4' && !plan.bindable.includes(a.address) && !a.internal);
347
374
  const v6 = localAddresses().filter((a) => a.family === 'IPv6' && !plan.bindable.includes(a.address) && !a.internal).length;
348
375
  // warn, not info: when loopback is not among the bound addresses, "connection
@@ -352,7 +379,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
352
379
  app.log({
353
380
  level: 'warn',
354
381
  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.`,
382
+ + `${v6 ? ` (plus ${v6} IPv6 address(es))` : ''}${plan.bindable.some((h) => LOOPBACK.has(h)) ? '.' : ', and not on 127.0.0.1 either: use one of the addresses above from this machine too.'}`,
356
383
  });
357
384
  if (plan.missing.length) {
358
385
  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` });
@@ -443,27 +470,44 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
443
470
  // when its RPC slows past the monitor's own threshold (rpc.slowLatencyMs, 5 s) the next file waits
444
471
  // until it recovers (2026-09-14, the first Mac install: 18 s answers and 90 s timeouts while the
445
472
  // build ran flat out -- which turned out to be gettxoutsetinfo, not the build, but the pacing stays)
473
+ let phase = null, phaseAt = Date.now(), lastFlag = 0, lastProgressAt = Date.now();
474
+ const flagLine = () => {
475
+ const pct = status.total ? Math.round((100 * status.done) / status.total) : 0;
476
+ const quiet = Date.now() - lastProgressAt;
477
+ return `the address index is being built: ${status.phase ?? 'starting'} ${Number(status.done ?? 0).toLocaleString()} of ${Number(status.total ?? 0).toLocaleString()} (${pct}%)${status.rows ? `, ${status.rows.toLocaleString()} rows so far` : ''}${status.eta ? `, about ${status.eta} left` : ''}${status.paused ? ' -- paused while the node\'s RPC is slow' : quiet > 120_000 ? ` -- no progress for ${Math.round(quiet / 60000)} min` : ''}`;
478
+ };
446
479
  const pace = rpcPacer(m.rpc, { slowMs: cfg.rpc?.slowLatencyMs ?? 5000, onChange: (held, t) => {
447
480
  status.paused = held;
481
+ lastFlag = Date.now(); m.flagQuality?.('address-index-building', flagLine(), 'info'); // on the flag the moment it changes
448
482
  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
483
  } });
450
484
  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
485
  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;
486
+ // THE FLAG IS REWRITTEN ON A CLOCK, NOT ONLY ON PROGRESS (2026-09-15: "scan 5,720 of 5,721,
487
+ // about 1 s left (88m ago)" -- a build that had stopped moving showed its last good line,
488
+ // and a pause showed nothing at all until the next file finished). Every 30 s it says how
489
+ // long since anything happened, and a pause is on it the moment it begins.
490
+ const heartbeat = setInterval(() => { if (Date.now() - lastFlag > 25_000) { lastFlag = Date.now(); m.flagQuality?.('address-index-building', flagLine(), 'info'); } }, 30_000);
491
+ heartbeat.unref?.();
453
492
  buildIndex({
454
493
  rpc, blocksDir: path.join(m.cfg.datadir, 'blocks'), out: dir, workers, pace,
494
+ // AN INTERRUPTED BUILD RESUMES (build.js, THE BUILD JOURNAL): where it resumed, or why the
495
+ // unfinished work was thrown away, is an event like the start
496
+ log: (text) => say(text),
455
497
  onProgress: (p) => {
456
498
  if (p.phase !== phase) { phase = p.phase; phaseAt = Date.now(); }
457
499
  const elapsed = (Date.now() - phaseAt) / 1000;
458
- const rate = elapsed > 0 && p.done > 0 ? p.done / elapsed : 0;
500
+ // a resumed build starts the phase part way (`from`): the rate is this run's, not the earlier one's
501
+ const rate = elapsed > 0 && p.done > (p.from ?? 0) ? (p.done - (p.from ?? 0)) / elapsed : 0;
459
502
  Object.assign(status, { phase: p.phase, done: p.done, total: p.total, rows: p.rows ?? status.rows, eta: rate > 0 && p.total > p.done ? HMS((p.total - p.done) / rate) : null });
503
+ lastProgressAt = Date.now();
460
504
  if (Date.now() - lastFlag > 5000) {
461
505
  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');
506
+ m.flagQuality?.('address-index-building', flagLine(), 'info');
464
507
  }
465
508
  },
466
509
  }).then((manifest) => {
510
+ clearInterval(heartbeat);
467
511
  registerIndexBuild(dir, null);
468
512
  m.indexBuild = null;
469
513
  m.clearQuality?.('address-index-building');
@@ -471,6 +515,7 @@ export async function boot({ configFile, log: logOverride = null } = {}) {
471
515
  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
516
  try { follow(dir, m); } catch (err) { say(`address index ${dir}: built, but the follower could not start: ${err.message}`, 'warn'); }
473
517
  }).catch((err) => {
518
+ clearInterval(heartbeat);
474
519
  status.error = err.message;
475
520
  registerIndexBuild(dir, null);
476
521
  m.indexBuild = null;
@@ -593,6 +638,8 @@ function installShutdown(app) {
593
638
  });
594
639
  }
595
640
 
641
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
642
+
596
643
  export function banner(app) {
597
644
  const lines = [];
598
645
  const host = app.cfg.server.host;
@@ -608,7 +655,15 @@ export function banner(app) {
608
655
  // Same content as the boot warning, in the banner: the first thing on screen
609
656
  // after `npm start` should be the sentence about who can read the node.
610
657
  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');
658
+ lines.push(' user admin, the audit trail and node writes stay closed; BLOCKYARD_AUTH=1 turns accounts back on');
659
+ }
660
+ // LOOPBACK IS THE DEFAULT (2026-09-15): say how to reach it from anywhere else, because "it
661
+ // works on the box and nowhere else" is the first thing a new install runs into now
662
+ const hosts = app.cfg.server.hosts ?? [host];
663
+ if (hosts.every((h) => LOOPBACK.has(h))) {
664
+ const p = app.cfg.server.port;
665
+ lines.push(` reach this machine only. From elsewhere: ssh -L ${p}:127.0.0.1:${p} you@this-host, then ${app.scheme}://localhost:${p}`);
666
+ lines.push(' or bind a LAN address: BLOCKYARD_BIND=192.0.2.10 (or 0.0.0.0 for every interface) — docs/INSTALL.md §7');
612
667
  }
613
668
  lines.push(` nodes ${[...app.monitors.values()].map((m) => `${m.id} -> ${m.rpc.url}`).join(', ')}`);
614
669
  if (app.cfg.server.allowCidrs.length) lines.push(` CIDRs ${app.cfg.server.allowCidrs.join(', ')}`);
@@ -48,8 +48,33 @@ const ALLOW_PREFIXES = ['get', 'list', 'estimate', 'verify', 'estimat', 'help',
48
48
  // transactions, which is a write in every way that matters.
49
49
  const DENY_PREFIXES = ['generate', 'invalidate', 'reconsider', 'import', 'send', 'set', 'unload', 'load', 'sign'];
50
50
 
51
+ // EVERY WALLET RPC, READ-SHAPED OR NOT (audit 2026-09-16, M4). The `get` and `list` prefixes let
52
+ // `listdescriptors true` and `gethdkeys {"private":true}` through, and both return private keys
53
+ // from an unlocked wallet -- to anyone the console serves, which in open mode is anyone who can
54
+ // reach the port. The monitor has no use for a wallet, so the whole category is refused by name.
55
+ // The list is Bitcoin Core's own "== Wallet ==" section of `help` (Core 29/30, taken 2026-09-16),
56
+ // plus the legacy-wallet methods older nodes still answer.
57
+ export const WALLET_METHODS = new Set([
58
+ 'abandontransaction', 'abortrescan', 'addhdkey', 'backupwallet', 'bumpfee', 'createwallet',
59
+ 'createwalletdescriptor', 'encryptwallet', 'exportwatchonlywallet', 'getaddressesbylabel',
60
+ 'getaddressinfo', 'getbalance', 'getbalances', 'gethdkeys', 'getnewaddress', 'getrawchangeaddress',
61
+ 'getreceivedbyaddress', 'getreceivedbylabel', 'gettransaction', 'getwalletinfo', 'importdescriptors',
62
+ 'importprunedfunds', 'keypoolrefill', 'listaddressgroupings', 'listdescriptors', 'listlabels',
63
+ 'listlockunspent', 'listreceivedbyaddress', 'listreceivedbylabel', 'listsinceblock', 'listtransactions',
64
+ 'listunspent', 'listwalletdir', 'listwallets', 'loadwallet', 'lockunspent', 'migratewallet',
65
+ 'psbtbumpfee', 'removeprunedfunds', 'rescanblockchain', 'restorewallet', 'send', 'sendall', 'sendmany',
66
+ 'sendtoaddress', 'setlabel', 'setwalletflag', 'signmessage', 'signrawtransactionwithwallet',
67
+ 'simulaterawtransaction', 'unloadwallet', 'walletcreatefundedpsbt', 'walletdisplayaddress', 'walletlock',
68
+ 'walletpassphrase', 'walletpassphrasechange', 'walletprocesspsbt',
69
+ // legacy (pre-descriptor) wallets
70
+ 'dumpprivkey', 'dumpwallet', 'importprivkey', 'importaddress', 'importpubkey', 'importmulti',
71
+ 'importwallet', 'sethdseed', 'upgradewallet', 'getunconfirmedbalance', 'listaccounts', 'getaccount',
72
+ 'getaccountaddress', 'getaddressesbyaccount', 'getreceivedbyaccount', 'listreceivedbyaccount',
73
+ ]);
74
+
51
75
  export function classifyMethod(method) {
52
76
  if (typeof method !== 'string' || !method) return { allowed: false, kind: 'unknown', reason: 'method name must be a string' };
77
+ if (WALLET_METHODS.has(method)) return { allowed: false, kind: 'wallet', reason: 'wallet RPCs are not exposed: some of them return private keys, and the monitor has no use for a wallet' };
53
78
  if (DENY_EXACT.has(method)) return { allowed: false, kind: 'write-or-heavy', reason: 'this method mutates state or monopolises the node\'s single-threaded RPC server' };
54
79
  for (const p of DENY_PREFIXES) if (method.startsWith(p) && !ALLOW_PREFIXES.includes(method)) return { allowed: false, kind: 'write', reason: `method starts with "${p}" and is treated as a mutation` };
55
80
  if (method === 'help' || method === 'uptime' || method === 'stop') {
@@ -73,6 +98,7 @@ export const NODE_REFUSES = new Set([
73
98
  export function allowlistSummary() {
74
99
  return {
75
100
  denyExactCount: DENY_EXACT.size,
101
+ walletDenied: WALLET_METHODS.size,
76
102
  allowPrefixes: [...new Set(ALLOW_PREFIXES)].sort(),
77
103
  denyPrefixes: [...new Set(DENY_PREFIXES)].sort(),
78
104
  defaultDecision: 'deny',
@@ -34,6 +34,34 @@ import http from 'node:http';
34
34
  import https from 'node:https';
35
35
  import { resolveCookie } from '../config.js';
36
36
 
37
+
38
+ /**
39
+ * A file path as it may be shown to a viewer (audit 2026-09-16, L11): its last two parts, which say
40
+ * which cookie or log it is (`main/.cookie`, `bitcoin/debug.log`) without the directories above --
41
+ * a home directory in them names the account the node runs as, and in open mode anyone who can
42
+ * reach the port reads these responses.
43
+ */
44
+ export function shortPath(p) {
45
+ if (typeof p !== 'string' || !p) return p ?? null;
46
+ const parts = p.split(/[\\/]+/).filter(Boolean);
47
+ return parts.length <= 2 ? parts.join('/') : `…/${parts.slice(-2).join('/')}`;
48
+ }
49
+
50
+ /**
51
+ * The RPC URL as it may be shown to a viewer (audit 2026-09-16, L3): never with a username or
52
+ * password in it. `http://user:pass@host:8332` is a valid rpcUrl, and the endpoint is shown on the
53
+ * Node page and in the node picker's tooltip to everyone who can read the monitor.
54
+ */
55
+ export function displayUrl(u) {
56
+ if (typeof u !== 'string' || !u) return u ?? null;
57
+ try {
58
+ const url = new URL(u);
59
+ if (!url.username && !url.password) return u;
60
+ url.username = ''; url.password = '';
61
+ return url.toString();
62
+ } catch { return u.replace(/\/\/[^@/]*@/, '//'); }
63
+ }
64
+
37
65
  export class RpcError extends Error {
38
66
  constructor(message, { code = null, httpStatus = null, kind = 'rpc' } = {}) {
39
67
  super(message);
@@ -399,8 +427,8 @@ export class RpcClient {
399
427
  telemetry() {
400
428
  return {
401
429
  nodeId: this.id,
402
- url: this.node.rpcUrl,
403
- cookieSource: this._cookieSource,
430
+ url: displayUrl(this.node.rpcUrl),
431
+ cookieSource: shortPath(this._cookieSource),
404
432
  online: !!this.lastGoodAt && !this.lane.breakerOpen && (!this.lastError || (this.lastGoodAt > this.lastError.at)),
405
433
  lastGoodAt: this.lastGoodAt,
406
434
  lastError: this.lastError,
@@ -14,7 +14,7 @@
14
14
  import fsp from 'node:fs/promises';
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
- import { appendJsonl } from './history.js';
17
+ import { appendJsonl, fchmodOwnerOnly } from './history.js';
18
18
 
19
19
  export class AuditLog {
20
20
  constructor(file, { maxBytes = 8 * 1024 * 1024, keep = 5, log = () => {} } = {}) {
@@ -129,6 +129,11 @@ export class AuditLog {
129
129
 
130
130
  /** Adopt an existing file's size so the first append after a restart is correct. */
131
131
  async adopt() {
132
+ // THE MODE IS TIGHTENED ON FILES THAT ALREADY EXIST (audit 2026-09-16, L10): appendJsonl passes
133
+ // 0o600, which applies only when it creates the file, so a trail written before 2026-09-14 kept
134
+ // whatever the umask gave it. Opened without following a symlink where the platform allows, and
135
+ // fchmod'ed through the descriptor.
136
+ for (const f of this.chain()) await fchmodOwnerOnly(f);
132
137
  try {
133
138
  this.bytes = (await fsp.stat(this.file)).size;
134
139
  return { adopted: this.bytes };
@@ -7,6 +7,7 @@
7
7
  // whole"), and it is the only sane answer when the process can be SIGKILLed
8
8
  // mid-flush by a system OOM killer this box has actually triggered before.
9
9
  import fsp from 'node:fs/promises';
10
+ import fs from 'node:fs';
10
11
  import path from 'node:path';
11
12
  import { Ring } from './ring.js';
12
13
 
@@ -141,7 +142,7 @@ export class History {
141
142
  if (this.saving) return { skipped: true };
142
143
  this.saving = true;
143
144
  try {
144
- await fsp.mkdir(this.dir, { recursive: true });
145
+ await fsp.mkdir(this.dir, { recursive: true, mode: 0o700 });
145
146
  const payload = {
146
147
  version: 1,
147
148
  savedAt: Date.now(),
@@ -151,7 +152,10 @@ export class History {
151
152
  eventsSeq: this.eventsSeq,
152
153
  };
153
154
  const tmp = `${this.file}.tmp`;
154
- const fh = await fsp.open(tmp, 'w', 0o600); // node-derived detail: owner-only, like users and sessions
155
+ // node-derived detail: owner-only, like users and sessions. The temporary name is unlinked and
156
+ // created with O_EXCL, so a symlink planted there is never followed (audit 2026-09-16, L10)
157
+ await fsp.unlink(tmp).catch((err) => { if (err.code !== 'ENOENT') throw err; });
158
+ const fh = await fsp.open(tmp, 'wx', 0o600);
155
159
  await fh.writeFile(JSON.stringify(payload));
156
160
  await fh.sync();
157
161
  await fh.close();
@@ -170,6 +174,7 @@ export class History {
170
174
  }
171
175
 
172
176
  async load() {
177
+ await fchmodOwnerOnly(this.file); // a snapshot written before the mode was passed (audit 2026-09-16, L10)
173
178
  let raw;
174
179
  try {
175
180
  raw = await fsp.readFile(this.file, 'utf8');
@@ -215,6 +220,17 @@ export class History {
215
220
 
216
221
  // Atomic append-only text sink, used by the audit log.
217
222
  export async function appendJsonl(file, row) {
218
- await fsp.mkdir(path.dirname(file), { recursive: true });
223
+ await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
219
224
  await fsp.appendFile(file, JSON.stringify(row) + '\n', { encoding: 'utf8', mode: 0o600 }); // who did what: owner-only (audit 2026-09-14, L4)
220
225
  }
226
+
227
+ // An existing file made owner-only through its own descriptor (audit 2026-09-16, L10). O_NOFOLLOW
228
+ // where the platform has it, so a symlink's target is never re-moded; a missing file, or a platform
229
+ // that keeps no modes (Windows), is not an error.
230
+ export async function fchmodOwnerOnly(file) {
231
+ let fh;
232
+ try {
233
+ fh = await fsp.open(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
234
+ await fh.chmod(0o600);
235
+ } catch { /* absent, a symlink, or modes not kept here */ } finally { await fh?.close().catch(() => {}); }
236
+ }
@@ -54,7 +54,7 @@ async function sqliteEngine() {
54
54
 
55
55
  export async function openLedger({ file, engine = 'auto', keepHeights = 52_594, log = () => {} } = {}) {
56
56
  if (!file) throw new Error('openLedger needs a file path');
57
- fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); // owner-only (audit 2026-09-16, L10)
58
58
  let chosen = engine;
59
59
  if (engine === 'auto') {
60
60
  chosen = (process.env.BLOCKYARD_LEDGER_ENGINE ?? 'sqlite').trim().toLowerCase();
@@ -90,6 +90,9 @@ class SqliteLedger {
90
90
  height INTEGER PRIMARY KEY, pool_key TEXT, pool_label TEXT, seen_at INTEGER, row TEXT
91
91
  )`);
92
92
  this.db.exec('CREATE INDEX IF NOT EXISTS attribution_seen ON attribution(seen_at DESC)');
93
+ // SQLite creates its files by the umask: tightened to owner-only, and an older file with them
94
+ // (audit 2026-09-16, L10)
95
+ for (const f of [file, `${file}-wal`, `${file}-shm`]) ownerOnly(f);
93
96
  this.insert = this.db.prepare('INSERT OR REPLACE INTO attribution VALUES (?,?,?,?,?)');
94
97
  }
95
98
 
@@ -164,7 +167,8 @@ class JsonlLedger {
164
167
  if (Number.isInteger(row?.height)) this.byHeight.set(row.height, row);
165
168
  } catch { /* the incomplete final write; ignored, then overwritten */ }
166
169
  }
167
- this.fd = fs.openSync(file, 'a');
170
+ this.fd = fs.openSync(file, 'a', 0o600);
171
+ try { fs.fchmodSync(this.fd, 0o600); } catch { /* not every filesystem keeps modes */ } // one created before (audit 2026-09-16, L10)
168
172
  }
169
173
 
170
174
  put(row) { return this.putMany([row]); }
@@ -188,13 +192,15 @@ class JsonlLedger {
188
192
  #compact() {
189
193
  const rows = [...this.byHeight.values()].sort((a, b) => a.height - b.height);
190
194
  const tmp = `${this.file}.tmp`;
191
- const fd = fs.openSync(tmp, 'w');
195
+ // never a planted symlink followed: unlinked, then created afresh and owner-only (audit 2026-09-16, L10)
196
+ try { fs.unlinkSync(tmp); } catch (err) { if (err.code !== 'ENOENT') throw err; }
197
+ const fd = fs.openSync(tmp, 'wx', 0o600);
192
198
  for (const r of rows) fs.writeSync(fd, JSON.stringify(r) + '\n');
193
199
  fs.fsyncSync(fd);
194
200
  fs.closeSync(fd);
195
201
  fs.closeSync(this.fd);
196
202
  fs.renameSync(tmp, this.file);
197
- this.fd = fs.openSync(this.file, 'a');
203
+ this.fd = fs.openSync(this.file, 'a', 0o600);
198
204
  this.dirty = 0;
199
205
  }
200
206
 
@@ -288,3 +294,8 @@ export function aggregate(rows = []) {
288
294
  }))
289
295
  .sort((a, b) => b.blocks - a.blocks || b.lastHeight - a.lastHeight);
290
296
  }
297
+
298
+ // chmod 0o600 when the file exists; a missing file or a filesystem without modes is not an error
299
+ function ownerOnly(file) {
300
+ try { fs.chmodSync(file, 0o600); } catch { /* absent, or modes not kept here */ }
301
+ }