blockyard 0.1.0 → 0.1.2

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 +112 -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 +247 -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 +1071 -0
  32. package/public/js/scorchedai.js +268 -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 +1361 -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 +27 -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
@@ -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
+ }
@@ -84,15 +84,46 @@ Environment=BLOCKYARD_LOG_LEVEL=info
84
84
  #Environment=BLOCKYARD_ENABLE_ACTIONS=1
85
85
  #Environment=BLOCKYARD_ACTIONS=testmempoolaccept,savemempool
86
86
 
87
- # Modest hardening. NoNewPrivileges plus a read-only /home is enough here: the
88
- # service must read the node's RPC cookie and log, so a full PrivateTmp/
89
- # ProtectSystem=strict would fight the thing it is monitoring.
87
+ # SANDBOX (audit 2026-09-16, M8). This block used to be "modest hardening", and its comment said
88
+ # NoNewPrivileges plus a read-only /home was enough -- but no read-only /home was ever set, and it
89
+ # claimed ProtectSystem=strict would stop the service reading the node's cookie. It would not:
90
+ # strict makes paths READ-ONLY, and reading is all the monitor does with the node. Everything below
91
+ # was started and checked against this app (tests/unit-sandbox note in docs/INSTALL.md section 6).
92
+ #
93
+ # The whole filesystem is read-only to the service except what it writes: its data directory
94
+ # (sessions, history, audit trail, TLS certificate), its config directory (the node form and the
95
+ # Display settings save there), and the address index if you configured one. EDIT ME: these paths
96
+ # follow WorkingDirectory above; add the index directory (`addressIndex` in config/local.json) as a
97
+ # third ReadWritePaths line. The leading "-" skips a path that does not exist yet.
98
+ ReadWritePaths=-/storage/blockyard/data
99
+ ReadWritePaths=-/storage/blockyard/config
100
+ #ReadWritePaths=/var/lib/blockyard-index
101
+ ProtectSystem=strict
102
+ # The node's cookie and log are usually under a home directory: readable, never writable.
103
+ ProtectHome=read-only
104
+ PrivateTmp=true
105
+ PrivateDevices=true
106
+ UMask=0077
90
107
  NoNewPrivileges=true
108
+ CapabilityBoundingSet=
109
+ AmbientCapabilities=
91
110
  ProtectKernelTunables=true
92
111
  ProtectKernelModules=true
112
+ ProtectKernelLogs=true
93
113
  ProtectControlGroups=true
114
+ ProtectClock=true
115
+ ProtectHostname=true
116
+ ProtectProc=invisible
117
+ RestrictNamespaces=true
118
+ RestrictRealtime=true
94
119
  RestrictSUIDSGID=true
95
120
  LockPersonality=true
121
+ # AF_NETLINK: the boot check that a configured address exists reads the interfaces through it.
122
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
123
+ SystemCallArchitectures=native
124
+ SystemCallFilter=@system-service
125
+ SystemCallErrorNumber=EPERM
126
+ # NOT MemoryDenyWriteExecute: V8's JIT needs writable-executable memory, and node aborts under it.
96
127
 
97
128
  StandardOutput=journal
98
129
  StandardError=journal