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